diff --git a/modules/EntraTokenAid.psm1 b/modules/EntraTokenAid.psm1
index b56d397..f24b463 100644
--- a/modules/EntraTokenAid.psm1
+++ b/modules/EntraTokenAid.psm1
@@ -1,10 +1,10 @@
-<#
+<#
.Synopsis
Pure PowerShell Entra OAuth authentication to get access and refresh tokens.
.Description
EntraTokenAid is a PowerShell module to simplify OAuth workflows with Microsoft Entra ID, to get the access and refresh token for different APIs using different clients.
- Accessing cleartext access and refresh tokens for various MS APIs (e.g., MS Graph) is often a requirement during engagements and research, especially using pre-consented clients (e.g., AzureCLI) to avoid additional consent prompts. Tokens are needed not only for manual enumeration via APIs but also for tools like AzureHound or GraphRunner, which require a valid refresh token.
+ Accessing cleartext access and refresh tokens for various MS APIs (e.g., MS Graph) is often a requirement during engagements and research, especially using pre-consented clients (e.g., AzureCLI) to avoid additional consent prompts. Tokens are needed not only for manual enumeration via APIs but also for tools like AzureHound or GraphRunner, which require a valid refresh token.
With more customers starting to block the Device Code Flow, alternative authentication methods for obtaining cleartext refresh tokens are becoming increasingly important. While using AzureCLI modules is a common solution, its installation may not always be feasible—especially on customer systems. Other alternatives like roadtx require Python, which might not be ideal in customer environments.
This tool should bridges this gap with a lightweight, standalone PowerShell solution that works even on the customers Windows systems.
@@ -90,15 +90,15 @@ function Invoke-Auth {
Performs OAuth 2.0 authentication using the Authorization Code Flow for Microsoft Entra ID.
.DESCRIPTION
- The `Invoke-Auth` function facilitates OAuth 2.0 Authorization Code Flow to get access and refresh tokens. It supports flexible configuration options, including scope, tenant, and client ID customization. The function can optionally output tokens, parse JWTs, or suppress PKCE, use CAE, and other standard authentication features.
+ The `Invoke-Auth` function facilitates OAuth 2.0 Authorization Code Flow to get access and refresh tokens. It supports flexible configuration options, including scope, tenant, and client ID customization. The function can optionally output tokens, parse JWTs, or suppress PKCE, use CAE, and other standard authentication features.
This function is particularly useful for penetration testers and security researchers who need cleartext access/refresh tokens to interact with Microsoft APIs like Microsoft Graph.
.PARAMETER Port
- Specifies the local port number for the redirection URI used during the authorization process.
+ Specifies the local port number for the redirection URI used during the authorization process.
Default: 13824
.PARAMETER ClientID
- Specifies the client ID of the application being authenticated.
+ Specifies the client ID of the application being authenticated.
Default: `04b07795-8ddb-461a-bbee-02f9e1bf7b46` (Microsoft Azure CLI)
.PARAMETER Scope
@@ -128,7 +128,7 @@ function Invoke-Auth {
.PARAMETER DisablePrompt
Prevents user selection in the browser during authentication (silent authentication).
-
+
.PARAMETER UserAgent
Specifies the user agent string to be used in the HTTP requests (not will only impact non-interactive sign-ins).
Default: `python-requests/2.32.3`
@@ -207,8 +207,6 @@ function Invoke-Auth {
[Parameter(Mandatory=$false)][string]$LoginHint
)
- $AuthError = $false
-
#Check whether the manual code flow, local HTTP server or embeded browser needs to be started.
if ($ManualCode) {
$AuthMode = "ManualCode"
@@ -262,7 +260,7 @@ function Invoke-Auth {
if ($LoginHint) {
$Url += "&login_hint=$LoginHint"
}
-
+
#Check if CAE is wanted
if (-not $DisableCAE) {
$Url += '&claims={%22access_token%22:%20{%22xms_cc%22:%20{%22values%22:%20[%22CP1%22]}}}'
@@ -273,7 +271,7 @@ function Invoke-Auth {
# Start auth flow in Browser
Start-Process $Url
# Http Server
- $HttpListener = [System.Net.HttpListener]::new()
+ $HttpListener = [System.Net.HttpListener]::new()
$HttpListener.Prefixes.Add("http://localhost:$Port/")
Try {
$HttpListener.Start()
@@ -286,7 +284,7 @@ function Invoke-Auth {
write-host "[!] ERROR: $HttpStartError"
}
}
-
+
if ($HttpListener.IsListening) {
write-host "[+] HTTP server running on http://localhost:$Port/"
write-host "[i] Listening for OAuth callback for $HttpTimeout s (HttpTimeout value) "
@@ -361,7 +359,7 @@ function Invoke-Auth {
}
}
}
-
+
#Spawn local HTTP server to catch the auth code
if ($AuthMode -eq "LocalHTTP") {
@@ -380,7 +378,7 @@ function Invoke-Auth {
try {
while ($Proceed) {
Start-Sleep -Milliseconds 500
-
+
# Check if the runtime exceeds the timeout (if set)
if ($HttpTimeout -gt 0 -and ([datetime]::Now - $StartTime).TotalSeconds -ge $HttpTimeout) {
Write-Host "[!] Runtime limit reached. Stopping the server..."
@@ -394,7 +392,7 @@ function Invoke-Auth {
}
break
}
-
+
# Process output from the shared queue
$Request = $null
while ($RequestQueue.TryDequeue([ref]$Request) -and $Proceed) {
@@ -405,12 +403,12 @@ function Invoke-Auth {
write-host "[+] Got OAuth callback request containing CODE"
$RawUrl = $($Request.RawUrl)
-
+
#Get content of the GET parameters
$QueryString = $RawUrl -replace '^.*\?', ''
$Params = $QueryString -split '&'
$QueryParams = @{}
-
+
# Iterate over each parameter and split into key-value pairs
foreach ($Param in $Params) {
$Key, $Value = $Param -split '=', 2
@@ -418,7 +416,7 @@ function Invoke-Auth {
}
$AuthorizationCode = $QueryParams["code"]
$StateResponse = $QueryParams["state"]
-
+
if ($StateResponse -ne $State) {
write-host "[!] Error: Wrong state received from IDP. Aborting..."
write-host "[!] Error: Received $StateResponse but expected $State"
@@ -439,23 +437,23 @@ function Invoke-Auth {
write-host "[!] Got OAuth callback request containing an ERROR"
$QueryString = $($Request.QueryString)
$RawUrl = $($Request.RawUrl)
-
+
#Get content of the GET parameters
$QueryString = $RawUrl -replace '^.*\?', ''
$Params = $QueryString -split '&'
$QueryParams = @{}
-
+
# Iterate over each parameter and split into key-value pairs
foreach ($Param in $Params) {
$Key, $Value = $Param -split '=', 2
$QueryParams[$Key] = $Value
}
-
+
#Define errors
$ErrorShort = $QueryParams["error"]
- $ErrorDescription = [System.Web.HttpUtility]::UrlDecode($QueryParams["error_description"])
- $MoreInfo = [System.Web.HttpUtility]::UrlDecode($QueryParams["error_uri"])
-
+ $ErrorDescription = [System.Web.HttpUtility]::UrlDecode($QueryParams["error_description"])
+ $MoreInfo = [System.Web.HttpUtility]::UrlDecode($QueryParams["error_uri"])
+
write-host "[!] Error in OAuth Callback: $ErrorShort"
write-host "[!] Description: $ErrorDescription"
write-host "[!] More info: $MoreInfo"
@@ -480,7 +478,7 @@ function Invoke-Auth {
}
}
-
+
} finally {
#Cleaning up
Write-Host "[*] Stopping the server..."
@@ -536,7 +534,7 @@ function Invoke-Auth {
if ($Url -match 'code=[^&]*') {
$Form.Close()
} elseif ($Url -match 'https://login.microsoftonline.com/') { #Section to capture the MS login errors
-
+
#Scanning URL for code or error parameters and the body for strings which appears on errors
if ($Url -match 'error=[^&]*') {
write-host "[!] Error parameter in URL detected"
@@ -553,9 +551,9 @@ function Invoke-Auth {
#Create Error Object to use in reporting
$ErrorDetails = [PSCustomObject]@{
ClientID = $ClientID
- ErrorLong = $ErrorMessage
+ ErrorLong = $ErrorMessage
}
- Invoke-Reporting -ErrorDetails $ErrorDetails -OutputFile "Auth_report_$($ReportName)_error.csv"
+ Invoke-Reporting -ErrorDetails $ErrorDetails -OutputFile "Auth_report_$($ReportName)_error.csv"
}
} else {
$Scripts = $WebBrowser.Document.GetElementsByTagName("script")
@@ -570,21 +568,21 @@ function Invoke-Auth {
#Create Error Object to use in reporting
$ErrorDetails = [PSCustomObject]@{
ClientID = $ClientID
- ErrorLong = $ErrorMessage
+ ErrorLong = $ErrorMessage
}
- Invoke-Reporting -ErrorDetails $ErrorDetails -OutputFile "Auth_report_$($ReportName)_error.csv"
+ Invoke-Reporting -ErrorDetails $ErrorDetails -OutputFile "Auth_report_$($ReportName)_error.csv"
}
}
}
}
}
-
+
})
$Form.Controls.Add($WebBrowser)
$Form.Add_Shown({$Form.Activate()})
-
+
$Form.ShowDialog() | Out-Null #Blocks until auth is complete
$AuthorizationCode = [System.Web.HttpUtility]::ParseQueryString($WebBrowser.Url.Query)['code']
@@ -595,7 +593,7 @@ function Invoke-Auth {
write-host "[+] Got an AuthCode"
#Use function to call the Token endpoint
$tokens = Get-Token -ClientID $ClientID -ApiScopeUrl $ApiScopeUrl -RedirectURL $RedirectURL -Tenant $Tenant -PKCE $PKCE -DisablePKCE $DisablePKCE -DisableCAE $DisableCAE -TokenOut $TokenOut -DisableJwtParsing $DisableJwtParsing -AuthorizationCode $AuthorizationCode -ReportName $ReportName -Reporting $Reporting -Origin $Origin -UserAgent $UserAgent
- return $tokens
+ return $tokens
}
}
@@ -609,7 +607,7 @@ function Invoke-Auth {
} else {
write-host "[i] Copy the full redirected URL (it contains the authorization code) to your clipboard."
}
-
+
Write-Host "[i] Press Enter when done, or press CTRL + C to abort."
$WaitForCode = $true
while ($WaitForCode) {
@@ -622,7 +620,7 @@ function Invoke-Auth {
$QueryString = $RawUrl -replace '^.*\?', ''
$Params = $QueryString -split '&'
$QueryParams = @{}
-
+
# Iterate over each parameter and split into key-value pairs
foreach ($Param in $Params) {
$Key, $Value = $Param -split '=', 2
@@ -651,7 +649,7 @@ function Invoke-Auth {
}
break
}
-
+
#Call the token endpoint
$tokens = Get-Token -ClientID $ClientID -ApiScopeUrl $ApiScopeUrl -RedirectURL $RedirectURL -Tenant $Tenant -PKCE $PKCE -DisablePKCE $DisablePKCE -DisableCAE $DisableCAE -TokenOut $TokenOut -DisableJwtParsing $DisableJwtParsing -AuthorizationCode $AuthorizationCode -ReportName $ReportName -Reporting $Reporting -Origin $Origin -UserAgent $UserAgent
return $tokens
@@ -667,19 +665,19 @@ function Invoke-Refresh {
Uses a refresh token to obtain a new access token, optionally for the same or a different API, or client.
.DESCRIPTION
- `Invoke-Refresh` allows users to exchange an existing refresh token for a new access token.
- It supports scenarios such as refreshing tokens for a different client or API, changing scopes,
+ `Invoke-Refresh` allows users to exchange an existing refresh token for a new access token.
+ It supports scenarios such as refreshing tokens for a different client or API, changing scopes,
or simply renewing tokens before expiration.
.PARAMETER RefreshToken
Specifies the refresh token to be exchanged for a new access token. This is a required parameter.
.PARAMETER ClientID
- Specifies the client ID of the application. Defaults to
+ Specifies the client ID of the application. Defaults to
(`04b07795-8ddb-461a-bbee-02f9e1bf7b46`) Azure CLI.
.PARAMETER Scope
- Defines the access scope requested in the new token. Defaults to `default offline_access`.
+ Defines the access scope requested in the new token. Defaults to `default offline_access`.
.PARAMETER Api
The base URL of the API for which the new access token is required. Defaults to `graph.microsoft.com`.
@@ -695,7 +693,7 @@ function Invoke-Refresh {
If specified, the function outputs the access token in the console.
.PARAMETER DisableJwtParsing
- Disables the automatic parsing of the access token's JWT payload.
+ Disables the automatic parsing of the access token's JWT payload.
.PARAMETER DisableCAE
Disables Continuous Access Evaluation (CAE) features when requesting the new token.
@@ -710,7 +708,7 @@ function Invoke-Refresh {
Define Origin Header to be used in the HTTP request.
.PARAMETER Reporting
- Enables logging (CSV) the details of the refresh operation for later analysis.
+ Enables logging (CSV) the details of the refresh operation for later analysis.
.EXAMPLE
# Example 1: Refresh an access token for the default client and API
@@ -747,7 +745,7 @@ function Invoke-Refresh {
"X-Client-Ver" = "1.31.0"
"X-Client-Os" = "win32"
}
-
+
#Add Origin if defined
if ($Origin) {
$Headers.Add("Origin", $Origin)
@@ -774,11 +772,11 @@ function Invoke-Refresh {
if (-not [string]::IsNullOrEmpty($BrkClientId)) {
$Body.Add("brk_client_id", $BrkClientId)
}
-
+
#Check if redirect uri is wanted
if (-not [string]::IsNullOrEmpty($RedirectUri)) {
$Body.Add("redirect_uri", $RedirectUri)
- }
+ }
Write-Host "[*] Sending request to token endpoint"
# Call the token endpoint to get the tokens
@@ -789,7 +787,7 @@ function Invoke-Refresh {
$tokens = Invoke-RestMethod "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/token" -Method POST -Body $Body -Headers $Headers
} Catch {
Write-Host "[!] Request Error:"
- $RequestError = $_
+ $RequestError = $_
$ParsedError = $null
# Check if $RequestError is valid JSON
@@ -825,7 +823,7 @@ function Invoke-Refresh {
Write-Host "[+] Got an access token (no refresh token requested)"
}
$tokens | Add-Member -NotePropertyName Expiration_time -NotePropertyValue (Get-Date).AddSeconds($tokens.expires_in)
-
+
if (-not $DisableJwtParsing) {
@@ -833,8 +831,8 @@ function Invoke-Refresh {
Try {
$JWT = Invoke-ParseJwt -jwt $tokens.access_token
} Catch {
-
- $JwtParseError = $_
+
+ $JwtParseError = $_
Write-Host "[!] JWT Parse error: $($JwtParseError)"
Write-Host "[!] Aborting...."
break
@@ -857,7 +855,7 @@ function Invoke-Refresh {
} else {
Write-Host "[i] Expires at: $($tokens.expiration_time)"
}
-
+
#Print token info if switch is used
if ($TokenOut) {
invoke-PrintTokenInfo -jwt $tokens -NotParsed $DisableJwtParsing
@@ -871,7 +869,7 @@ function Invoke-Refresh {
} elseif($Proceed) {
Write-Host "[!] The answer obtained from the token endpoint do not contains tokens"
}
-
+
}
function Invoke-DeviceCodeFlow {
@@ -880,12 +878,12 @@ function Invoke-DeviceCodeFlow {
Performs OAuth 2.0 authentication using the Device Code Flow.
.DESCRIPTION
- The `Invoke-DeviceCodeFlow` function facilitates OAuth 2.0 authentication using the Device Code Flow.
- This flow is ideal for scenarios where interactive login via a browser is required, but the client application runs in an environment where a browser is not readily available (e.g., CLI or limited UI environments).
+ The `Invoke-DeviceCodeFlow` function facilitates OAuth 2.0 authentication using the Device Code Flow.
+ This flow is ideal for scenarios where interactive login via a browser is required, but the client application runs in an environment where a browser is not readily available (e.g., CLI or limited UI environments).
The function automatically starts a browser session to complete authentication and copies the user code to the clipboard for convenience. Upon successful authentication, the function retrieves access and refresh tokens.
.PARAMETER ClientID
- Specifies the client ID of the application being authenticated.
+ Specifies the client ID of the application being authenticated.
Default: `04b07795-8ddb-461a-bbee-02f9e1bf7b46` (Microsoft Azure CLI)
.PARAMETER Api
@@ -895,7 +893,7 @@ function Invoke-DeviceCodeFlow {
.PARAMETER Scope
Specifies the API permissions (scopes) to request during authentication. Multiple scopes should be space-separated.
Default: `default offline_access`
-
+
.PARAMETER DisableJwtParsing
Disables parsing of the JWT access token. When set, the token is returned as-is without any additional information.
@@ -918,7 +916,7 @@ function Invoke-DeviceCodeFlow {
Default: `organizations`
.PARAMETER Reporting
- Enables logging (CSV) the details of the authentication operation for later analysis.
+ Enables logging (CSV) the details of the authentication operation for later analysis.
.EXAMPLE
Invoke-DeviceCodeFlow
@@ -948,10 +946,10 @@ function Invoke-DeviceCodeFlow {
)
$Proceed = $true
-
+
# Construct scope string for v2 endpoints
$ApiScopeUrl = Resolve-ApiScopeUrl -Api $Api -Scope $Scope
-
+
$Headers=@{}
$Headers["User-Agent"] = $UserAgent
@@ -967,7 +965,7 @@ function Invoke-DeviceCodeFlow {
Try {
$DeviceCodeDetails = Invoke-RestMethod "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/devicecode" -Method POST -Body $Body -Headers $Headers
} Catch {
- $InitialError = $_ | ConvertFrom-Json
+ $InitialError = $_ | ConvertFrom-Json
Write-Host "[!] Aborting...."
Write-Host "[!] Error: $($InitialError.error)"
Write-Host "[!] Error Description: $($InitialError.error_description)"
@@ -980,7 +978,7 @@ function Invoke-DeviceCodeFlow {
}
$Proceed = $false
}
-
+
if ($Proceed) {
Set-Clipboard $DeviceCodeDetails.user_code
write-host "[i] User code: $($DeviceCodeDetails.user_code). Copied to clipboard..."
@@ -1050,12 +1048,12 @@ function Invoke-DeviceCodeFlow {
# Parse the token
$JWT = Invoke-ParseJwt -jwt $TokensDeviceCode.access_token
} Catch {
- $JwtParseError = $_
+ $JwtParseError = $_
Write-Host "[!] JWT Parse error: $($JwtParseError)"
Write-Host "[!] Aborting...."
break
}
-
+
#Add additonal infos to token object
$TokensDeviceCode | Add-Member -NotePropertyName scp -NotePropertyValue $JWT.scp
$TokensDeviceCode | Add-Member -NotePropertyName tenant -NotePropertyValue $JWT.tid
@@ -1073,8 +1071,8 @@ function Invoke-DeviceCodeFlow {
} else {
Write-Host "[i] Expires at: $($TokensDeviceCode.expiration_time)"
}
-
-
+
+
#Print token info if switch is used
if ($TokenOut) {
invoke-PrintTokenInfo -jwt $TokensDeviceCode -NotParsed $DisableJwtParsing
@@ -1100,7 +1098,7 @@ function Invoke-ClientCredential {
Performs OAuth 2.0 authentication using the Client Credential Flow.
.DESCRIPTION
- The `Invoke-ClientCredential` function implements the OAuth 2.0 Client Credentials Flow.
+ The `Invoke-ClientCredential` function implements the OAuth 2.0 Client Credentials Flow.
It retrieves an access token for the specified API and supports additional features like JWT parsing, custom reporting, and secure handling of client secrets.
.PARAMETER ClientId
@@ -1108,7 +1106,7 @@ function Invoke-ClientCredential {
.PARAMETER ClientSecret
Specifies the client secret of the application being authenticated. If not provided, the function prompts for secure input during execution.
-
+
.PARAMETER Api
Specifies the target API for the authentication request.
Default: `graph.microsoft.com`
@@ -1116,7 +1114,7 @@ function Invoke-ClientCredential {
.PARAMETER Scope
Specifies the API permissions (scopes) to request during authentication. Multiple scopes should be space-separated.
Default: `default`
-
+
.PARAMETER DisableJwtParsing
Disables parsing of the JWT access token. When set, the token is returned as-is without any additional information.
@@ -1131,7 +1129,7 @@ function Invoke-ClientCredential {
Specifies the tenant ID for authentication. This parameter is mandatory.
.PARAMETER Reporting
- Enables logging (CSV) the details of the authentication operation for later analysis.
+ Enables logging (CSV) the details of the authentication operation for later analysis.
.EXAMPLE
Invoke-ClientCredential -ClientId "your-client-id" -ClientSecret "your-client-secret" -TenantId "your-tenant-id"
@@ -1150,7 +1148,7 @@ function Invoke-ClientCredential {
.NOTES
Ensure the client application has the appropriate permissions for the specified API and scope in Azure AD.
-
+
#>
param (
[Parameter(Mandatory=$true)][string]$ClientId,
@@ -1167,7 +1165,7 @@ function Invoke-ClientCredential {
$Proceed = $true
$Headers=@{}
$Headers["User-Agent"] = $UserAgent
-
+
#Prompt for client credential if not defined
if (-not $ClientSecret) {
$ClientSecretSecure = Read-Host -Prompt "Enter the client secret" -AsSecureString
@@ -1183,21 +1181,21 @@ function Invoke-ClientCredential {
# Construct scope string for v2 endpoints
$ApiScopeUrl = Resolve-ApiScopeUrl -Api $Api -Scope $Scope
-
- # Get Access Token
- $tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
+
+ # Get Access Token
+ $tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
$body = @{
'scope' = $ApiScopeUrl
- 'client_id' = $ClientId
+ 'client_id' = $ClientId
'client_secret' = $ClientSecret
- 'grant_type' = 'client_credentials'
+ 'grant_type' = 'client_credentials'
}
write-host "[*] Starting Client Credential flow: API $Api / Client id: $ClientID"
Try {
$TokensClientCredential = Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType "application/x-www-form-urlencoded" -Body $body -Headers $Headers
} Catch {
- $InitialError = $_ | ConvertFrom-Json
+ $InitialError = $_ | ConvertFrom-Json
Write-Host "[!] Aborting...."
Write-Host "[!] Error: $($InitialError.error)"
Write-Host "[!] Error Description: $($InitialError.error_description)"
@@ -1222,7 +1220,7 @@ function Invoke-ClientCredential {
# Parse the token
$JWT = Invoke-ParseJwt -jwt $TokensClientCredential.access_token
} Catch {
- $JwtParseError = $_
+ $JwtParseError = $_
Write-Host "[!] JWT Parse error: $($JwtParseError)"
Write-Host "[!] Aborting...."
break
@@ -1239,8 +1237,8 @@ function Invoke-ClientCredential {
} else {
Write-Host "[i] Expires at: $($TokensClientCredential.expiration_time)"
}
-
-
+
+
#Print token info if switch is used
if ($TokenOut) {
invoke-PrintTokenInfo -jwt $TokensClientCredential -NotParsed $DisableJwtParsing
@@ -1254,7 +1252,7 @@ function Invoke-ClientCredential {
}
}
- Return $TokensClientCredential
+ Return $TokensClientCredential
}
function Invoke-ParseJwt {
@@ -1263,7 +1261,7 @@ function Invoke-ParseJwt {
Parses the body of a JWT and returns the decoded contents as a PowerShell object.
.DESCRIPTION
- The `Invoke-ParseJwt` function parses a JSON Web Token (JWT) and decodes its payload (body).
+ The `Invoke-ParseJwt` function parses a JSON Web Token (JWT) and decodes its payload (body).
This is useful for analyzing token claims, scopes, expiration, and other metadata embedded in the JWT.
.PARAMETER Jwt
@@ -1286,19 +1284,19 @@ function Invoke-ParseJwt {
[cmdletbinding()]
param([Parameter(Mandatory=$true)][string]$jwt)
-
+
#JWT verification
- if (!$jwt.Contains(".") -or !$jwt.StartsWith("eyJ")) {
+ if (!$jwt.Contains(".") -or !$jwt.StartsWith("eyJ")) {
if ($jwt.StartsWith("1.")) {
Write-Error "Invalid token! The refresh token can not be parsed since it is encrypted." -ErrorAction Stop
} else {
- Write-Error "Invalid token!" -ErrorAction Stop
+ Write-Error "Invalid token!" -ErrorAction Stop
}
}
#Process Token Body
$TokenBody = $jwt.Split(".")[1].Replace('-', '+').Replace('_', '/')
-
+
#Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0
while ($TokenBody.Length % 4) { Write-Verbose "Invalid length for a Base-64 char array or string, adding ="; $TokenBody += "=" }
@@ -1321,7 +1319,7 @@ function Invoke-PrintTokenInfo {
The `Invoke-PrintTokenInfo` function is an internal utility designed to display claims and metadata from a JSON Web Token (JWT) in a readable, formatted manner. Depending on whether the token has been pre-parsed, it extracts and shows specific details.
.PARAMETER JWT
- Specifies the JSON Web Token (JWT) object containing the metadata to display.
+ Specifies the JSON Web Token (JWT) object containing the metadata to display.
.PARAMETER NotParsed
Indicates whether the JWT has not been pre-parsed. If set to `$true`, the function displays a reduced set of token details, assuming minimal processing has occurred.
@@ -1365,7 +1363,7 @@ function Invoke-PrintTokenInfo {
if ($JWT.roles) {Write-Host "Roles: $($JWT.roles)"}
} else {
Write-Host "Scope: $($JWT.scope)"
- }
+ }
if ($JWT.foci) {Write-Host "Foci: $($JWT.foci)"} else {Write-Host "Foci: 0" }
if ($JWT.xms_cc) {Write-Host "CAE (xms_cc): $($JWT.xms_cc)"} else {Write-Host "CAE (xms_cc): 0" }
@@ -1397,7 +1395,7 @@ function Invoke-Reporting {
Logs JWT information to a CSV file for internal analysis and comparison during mass testing.
.DESCRIPTION
- The `Invoke-Reporting` function is an internal utility designed to log selected claims and metadata from a JSON Web Token (JWT) to a CSV file.
+ The `Invoke-Reporting` function is an internal utility designed to log selected claims and metadata from a JSON Web Token (JWT) to a CSV file.
It is particularly useful for analyzing multiple tokens.
This function intended for internal use by other functions or scripts within the module.
If the specified CSV file does not exist, the function creates it with headers. If the file exists, the new data is appended without rewriting the headers.
@@ -1435,7 +1433,7 @@ function Invoke-Reporting {
$ErrorDetails | Add-Member -MemberType NoteProperty -Name "timestamp" -Value (Get-Date).ToString("o")
$SelectedInfo = $ErrorDetails | select-object timestamp,ClientID,ErrorLong
}
-
+
# Write to CSV with or without headers
if (-Not (Test-Path -Path $OutputFile)) {
@@ -1522,7 +1520,7 @@ function Get-Token {
write-host "[*] Calling the token endpoint"
-
+
#Define headers (emulate Azure CLI)
$Headers = @{
"User-Agent" = $UserAgent
@@ -1588,7 +1586,7 @@ function Get-Token {
}
Write-Host "[!] Error Details: $($TokenRequestError.error)"
Write-Host "[!] Error Description: $($TokenRequestError.error_description)"
-
+
if ($Reporting) {
$ErrorDetails = [PSCustomObject]@{
ClientID = $ClientID
@@ -1597,7 +1595,7 @@ function Get-Token {
Invoke-Reporting -ErrorDetails $ErrorDetails -OutputFile "Auth_report_$($ReportName)_error.csv"
}
return
-
+
}
#Check if answer contains an access token (refresh token can be omitted)
@@ -1616,7 +1614,7 @@ function Get-Token {
# Parse the token
$JWT = Invoke-ParseJwt -jwt $tokens.access_token
} Catch {
- $JwtParseError = $_
+ $JwtParseError = $_
Write-Host "[!] JWT Parse error: $($JwtParseError)"
Write-Host "[!] Aborting...."
@@ -1642,15 +1640,12 @@ function Get-Token {
$tokens | Add-Member -NotePropertyName api -NotePropertyValue ($JWT.aud -replace '^https?://', '' -replace '/$', '')
if ($null -ne $JWT.xms_cc) {
$tokens | Add-Member -NotePropertyName xms_cc -NotePropertyValue $JWT.xms_cc
- $xms_cc = $true
- } else {
- $xms_cc = $false
}
Write-Host "[i] Audience: $($JWT.aud) / Expires at: $($tokens.expiration_time)"
} else {
Write-Host "[i] Expires at: $($tokens.expiration_time)"
}
-
+
$AuthError = $false
if (-Not $AuthError) {
@@ -1658,7 +1653,7 @@ function Get-Token {
if ($TokenOut) {
invoke-PrintTokenInfo -jwt $tokens -NotParsed $DisableJwtParsing
}
-
+
#Check if report file should be written
if ($Reporting) {
Invoke-Reporting -jwt $tokens -OutputFile "Auth_report_$($ReportName).csv"
@@ -1684,7 +1679,7 @@ function Get-Token {
Invoke-Reporting -ErrorDetails $ErrorDetails -OutputFile "Auth_report_$($ReportName)_error.csv"
}
return
- }
+ }
}
@@ -1695,9 +1690,9 @@ function Show-EntraTokenAidHelp {
$banner = @'
______ __ ______ __ ___ _ __
/ ____/___ / /__________ /_ __/___ / /_____ ____ / | (_)___/ /
- / __/ / __ \/ __/ ___/ __ `// / / __ \/ //_/ _ \/ __ \/ /| | / / __ /
- / /___/ / / / /_/ / / /_/ // / / /_/ / ,< / __/ / / / ___ |/ / /_/ /
-/_____/_/ /_/\__/_/ \__,_//_/ \____/_/|_|\___/_/ /_/_/ |_/_/\__,_/
+ / __/ / __ \/ __/ ___/ __ `// / / __ \/ //_/ _ \/ __ \/ /| | / / __ /
+ / /___/ / / / /_/ / / /_/ // / / /_/ / ,< / __/ / / / ___ |/ / /_/ /
+/_____/_/ /_/\__/_/ \__,_//_/ \____/_/|_|\___/_/ /_/_/ |_/_/\__,_/
'@
# Header
diff --git a/modules/Send-ApiRequest.psm1 b/modules/Send-ApiRequest.psm1
index 6354eab..50ed8f9 100644
--- a/modules/Send-ApiRequest.psm1
+++ b/modules/Send-ApiRequest.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Sends a single API request with retry, pagination, and robust error parsing.
@@ -6,7 +6,7 @@
Send-ApiRequest is a generic wrapper around Invoke-RestMethod.
It supports automatic pagination, retry logic for transient failures,
custom headers/query parameters, proxy usage, and improved response error parsing.
-
+
.LINK
https://github.com/zh54321/Send-ApiRequest
#>
@@ -68,6 +68,8 @@ function Get-ApiErrorDetails {
$statusCode = [int]$response.StatusCode
}
} catch {
+ # Status code extraction is best-effort; format varies by exception type
+ Write-Verbose "Could not extract HTTP status code: $($_.Exception.Message)"
}
}
@@ -93,6 +95,8 @@ function Get-ApiErrorDetails {
}
}
} catch {
+ # Response body extraction is best-effort; stream may be closed or unavailable
+ Write-Verbose "Could not read response body: $($_.Exception.Message)"
}
}
@@ -113,6 +117,8 @@ function Get-ApiErrorDetails {
if ($parsedError.message) { $errorMessage = [string]$parsedError.message }
}
} catch {
+ # JSON parsing is best-effort; body may not be valid JSON
+ Write-Verbose "Could not parse error response as JSON: $($_.Exception.Message)"
}
}
diff --git a/modules/Send-GraphBatchRequest.psm1 b/modules/Send-GraphBatchRequest.psm1
index 67c03df..600a493 100644
--- a/modules/Send-GraphBatchRequest.psm1
+++ b/modules/Send-GraphBatchRequest.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Sends a batch request to Microsoft Graph API.
@@ -59,21 +59,21 @@
$Requests = @(
@{ "id" = "1"; "method" = "GET"; "url" = "/groups" }
)
-
+
Send-GraphBatchRequest -AccessToken $AccessToken -Requests $Requests -DebugMode
.EXAMPLE
$AccessToken = "YOUR_ACCESS_TOKEN"
$Requests = @(
- @{
+ @{
"id" = "1"
"method" = "POST"
"url" = "/groups"
"body" = @{ "displayName" = "New Group"; "mailEnabled" = $false; "mailNickname" = "whatever"; "securityEnabled" = $true }
- "headers" = @{"Content-Type"= "application/json"}
+ "headers" = @{"Content-Type"= "application/json"}
}
)
-
+
Send-GraphBatchRequest -AccessToken $AccessToken -Requests $Requests -RawJson
.EXAMPLE
@@ -301,9 +301,9 @@ function Send-GraphBatchRequest {
Write-Warning ("[{0}] [!] Missing first-page data for ID {1} - initializing empty list." -f (Get-Date -Format "HH:mm:ss"), $id)
$PagedResultsMap[$id] = New-Object 'System.Collections.Generic.List[object]'
}
-
+
$PagedResultsMap[$id].AddRange($BatchResult.values[$id])
-
+
if ($BatchResult.nextLinks.ContainsKey($id)) {
$GlobalNextLinks.Add("$id|$($BatchResult.nextLinks[$id])")
}
@@ -327,7 +327,10 @@ function Send-GraphBatchRequest {
}
+# JsonDepthResponse and VerboseMode are accepted from callers for interface consistency
function Invoke-GraphNextLinkBatch {
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'JsonDepthResponse')]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'VerboseMode')]
param (
[string[]]$NextLinks,
[string[]]$Ids,
@@ -343,9 +346,6 @@ function Invoke-GraphNextLinkBatch {
[string]$ApiVersion
)
- $ResultsList = New-Object 'System.Collections.Generic.List[object]'
- $MoreNextLinks = New-Object 'System.Collections.Generic.List[string]'
-
$Headers = @{
"Authorization" = "Bearer $AccessToken"
"User-Agent" = $UserAgent
@@ -416,19 +416,19 @@ function Invoke-GraphNextLinkBatch {
}
$ResultMap = @{}
$MoreLinksMap = @{}
-
+
foreach ($resp in $BatchResp.responses) {
$i = [int]($resp.id -replace 'nl_', '')
$realId = $Ids[$i]
-
+
if (-not $ResultMap.ContainsKey($realId)) {
$ResultMap[$realId] = New-Object 'System.Collections.Generic.List[object]'
}
-
+
if ($resp.body.value) {
$ResultMap[$realId].AddRange(@($resp.body.value))
}
-
+
if ($resp.body.'@odata.nextLink') {
$MoreLinksMap[$realId] = $resp.body.'@odata.nextLink'
}
diff --git a/modules/Send-GraphRequest.psm1 b/modules/Send-GraphRequest.psm1
index 80ebdcd..f0080f7 100644
--- a/modules/Send-GraphRequest.psm1
+++ b/modules/Send-GraphRequest.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Sends requests to the Microsoft Graph API.
@@ -59,10 +59,10 @@
.EXAMPLE
Send-GraphRequest -AccessToken $token -Method GET -Uri '/users' -AdditionalHeaders @{ 'ConsistencyLevel' = 'eventual' }
-.EXAMPLE
+.EXAMPLE
Send-GraphRequest -AccessToken $token -Method GET -Uri '/users' -QueryParameters @{ '$filter' = "startswith(displayName,'Alex')" }"
-.EXAMPLE
+.EXAMPLE
$Body = @{
displayName = "Test Security Group2"
mailEnabled = $false
@@ -71,7 +71,7 @@
groupTypes = @()
}
$result = Send-GraphRequest -AccessToken $token -Method POST -Uri "/groups" -Body $Body -VerboseMode
-
+
.NOTES
Author: ZH54321
@@ -110,13 +110,13 @@ function Send-GraphRequest {
#Add query parameters
if ($QueryParameters) {
- $QueryString = ($QueryParameters.GetEnumerator() |
- ForEach-Object {
- "$($_.Key)=$([uri]::EscapeDataString($_.Value))"
+ $QueryString = ($QueryParameters.GetEnumerator() |
+ ForEach-Object {
+ "$($_.Key)=$([uri]::EscapeDataString($_.Value))"
}) -join '&'
$FullUri = "$FullUri`?$QueryString"
}
-
+
#Define basic headers
$Headers = @{
@@ -223,7 +223,7 @@ function Send-GraphRequest {
} else {
if (-not ($StatusCode -eq 404 -and $Suppress404)) {
$msg = "[!] Graph API request failed after $RetryCount retries. Status: $StatusCode. Message: $StatusDesc"
- $exception = New-Object System.Exception($msg)
+ $exception = New-Object System.Exception($msg)
$errorRecord = New-Object System.Management.Automation.ErrorRecord (
$exception,
@@ -231,7 +231,7 @@ function Send-GraphRequest {
$errorCategory,
$FullUri
)
-
+
Write-Error $errorRecord
}
diff --git a/modules/check_AppRegistrations.psm1 b/modules/check_AppRegistrations.psm1
index 622cb59..31ad5fa 100644
--- a/modules/check_AppRegistrations.psm1
+++ b/modules/check_AppRegistrations.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Enumerate App Registrations (including: API Permission (Application), Owner, Secrets, Certificates, Access through App Roles etc.).
@@ -9,6 +9,8 @@
function Invoke-CheckAppRegistrations {
############################## Parameter section ########################
+ # AllGroupsDetails is used by the nested GetObjectInfo function via parent scope
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AllGroupsDetails')]
[CmdletBinding()]
Param (
[Parameter(Mandatory=$false)][string]$OutputFolder = ".",
@@ -47,7 +49,7 @@ function Invoke-CheckAppRegistrations {
} else {
$Department = $User.Department
}
- [PSCustomObject]@{
+ [PSCustomObject]@{
Type = "User"
DisplayName = $User.DisplayName
UPN= $User.UserPrincipalName
@@ -65,7 +67,7 @@ function Invoke-CheckAppRegistrations {
$MatchingGroup = $AllGroupsDetails[$($Object)]
if (($MatchingGroup | Measure-Object).count -ge 1) {
- [PSCustomObject]@{
+ [PSCustomObject]@{
Type = "Group"
Id = $MatchingGroup.Id
DisplayName = $MatchingGroup.DisplayName
@@ -83,7 +85,7 @@ function Invoke-CheckAppRegistrations {
$MatchingEnterpriseApp = $EnterpriseApps[$($Object)]
if (($MatchingEnterpriseApp | Measure-Object).count -ge 1) {
- [PSCustomObject]@{
+ [PSCustomObject]@{
Type = "ServicePrincipal"
Id = $MatchingEnterpriseApp.Id
DisplayName = $MatchingEnterpriseApp.DisplayName
@@ -103,14 +105,14 @@ function Invoke-CheckAppRegistrations {
$Expired = $False
}
}
- [PSCustomObject]@{
+ [PSCustomObject]@{
Type = "Secret"
DisplayName = $Object.DisplayName
EndDateTime = $Object.EndDateTime
Expired = $Expired
Hint = $Object.Hint
}
-
+
}
if ($type -eq "Cert" ) {
@@ -122,7 +124,7 @@ function Invoke-CheckAppRegistrations {
$Expired = $False
}
}
- [PSCustomObject]@{
+ [PSCustomObject]@{
Type = "Cert"
DisplayName = $Object.DisplayName
EndDateTime = $Object.EndDateTime
@@ -174,7 +176,7 @@ function Invoke-CheckAppRegistrations {
$AppsTotalCount = $($AppRegistrations.count)
Write-Log -Level Verbose -Message "Filtered out $AgentIdentityBlueprintCount agent identity blueprints from App Registrations."
}
-
+
write-host "[+] Got $AppsTotalCount App registrations"
#Abort if no apps are present
@@ -241,7 +243,7 @@ function Invoke-CheckAppRegistrations {
$ObjectDetails | Add-Member -MemberType NoteProperty -Name Role -Value 'CloudApplicationAdministrator'
$ObjectDetails | Add-Member -MemberType NoteProperty -Name Scope -Value 'Tenant' -PassThru
}
-
+
#Get members of Application Administrator (9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3) with the scope for current the Tenant
$AppAdminTenant = if ($AppAdminAssignmentsByScope.ContainsKey("/")) {
@($AppAdminAssignmentsByScope["/"])
@@ -306,7 +308,7 @@ function Invoke-CheckAppRegistrations {
if ($AppsTotalCount -gt 0 -and $StatusUpdateInterval -gt 1) {
Write-Host "[*] Status: Processing app 1 of $AppsTotalCount (updates every $StatusUpdateInterval apps)..."
}
-
+
#region Processing Loop
#Loop through each app and get additional info and store it in a custom object
foreach ($item in $AppRegistrations) {
@@ -417,7 +419,7 @@ function Invoke-CheckAppRegistrations {
WindowsRedirectUris = $item.Windows.RedirectUris
PublicClientRedirectUris = $item.PublicClient.RedirectUris
}
-
+
# Define patterns with severity levels
$RedirectPatterns = @(
[PSCustomObject]@{ Pattern = "*.azurewebsites.net"; Severity = "High" },
@@ -493,7 +495,7 @@ function Invoke-CheckAppRegistrations {
}
#Get application home page
- if ($null -ne $item.web.HomePageUrl) {
+ if ($null -ne $item.web.HomePageUrl) {
$AppHomePage = $item.web.HomePageUrl
}
@@ -549,7 +551,7 @@ function Invoke-CheckAppRegistrations {
} else {
@()
}
-
+
$CloudAppAdminCurrentAppDetails = foreach ($Object in $CloudAppAdminCurrentApp) {
# Get the object details
$ObjectDetails = GetObjectInfo $Object.PrincipalId
@@ -559,7 +561,7 @@ function Invoke-CheckAppRegistrations {
$ObjectDetails | Add-Member -MemberType NoteProperty -Name Role -Value 'CloudApplicationAdministrator'
$ObjectDetails | Add-Member -MemberType NoteProperty -Name Scope -Value 'ThisApplication' -PassThru
}
-
+
#Get members of Application Administrator (9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3) with the scope for current App Registrations
$AppAdminCurrentApp = if ($AppAdminAssignmentsByScope.ContainsKey($scopeKey)) {
@($AppAdminAssignmentsByScope[$scopeKey])
@@ -655,11 +657,11 @@ function Invoke-CheckAppRegistrations {
$Warnings += "Guest as scoped AppAdmin!"
$LikelihoodScore += $AppLikelihoodScore["GuestAsOwner"]
}
- if (($CloudAppAdminCurrentAppDetails | Where-Object { $_.Foreign -eq "True" } | Measure-Object).Count -ge 1) {
+ if (($CloudAppAdminCurrentAppDetails | Where-Object { $_.Foreign -eq $true } | Measure-Object).Count -ge 1) {
$Warnings += "Foreign SP as scoped CloudAppAdmin!"
$LikelihoodScore += $AppLikelihoodScore["ExternalSPOwner"]
}
- if (($AppAdminCurrentAppDetails | Where-Object { $_.Foreign -eq "True" } | Measure-Object).Count -ge 1) {
+ if (($AppAdminCurrentAppDetails | Where-Object { $_.Foreign -eq $true } | Measure-Object).Count -ge 1) {
$Warnings += "Foreign SP scoped AppAdmin!"
$LikelihoodScore += $AppLikelihoodScore["ExternalSPOwner"]
}
@@ -683,9 +685,9 @@ function Invoke-CheckAppRegistrations {
if ($SecretsCount -ge 1){
$AppsWithSecrets += $AppCredentialsSecrets
}
-
+
#Write custom object
- $AppRegDetails = [PSCustomObject]@{
+ $AppRegDetails = [PSCustomObject]@{
Id = $item.Id
DisplayName = $item.DisplayName
DisplayNameLink = "$($item.DisplayName)"
@@ -719,17 +721,17 @@ function Invoke-CheckAppRegistrations {
Warnings = $Warnings
}
[void]$AllAppRegistrations.Add($AppRegDetails)
-
+
}
#endregion
-
+
########################################## SECTION: OUTPUT DEFINITION ##########################################
write-host "[*] Generating reports"
#Define Table for output
$tableOutput = $AllAppRegistrations | Sort-Object -Property risk -Descending | select-object DisplayName,DisplayNameLink,Enabled,CreationInDays,SignInAudience,AppRoles,AppLock,Owners,CloudAppAdmins,AppAdmins,SecretsCount,CertsCount,FederatedCreds,Impact,Likelihood,Risk,Warnings
-
+
#Define the apps to be displayed in detail and sort them by risk score
$details = $AllAppRegistrations | Sort-Object Risk -Descending
@@ -737,7 +739,7 @@ function Invoke-CheckAppRegistrations {
#Define stringbuilder to avoid performance impact
$DetailTxtBuilder = [System.Text.StringBuilder]::new()
-
+
foreach ($item in $details) {
$ReportingAppRegInfo = @()
$ReportingCredentials = @()
@@ -749,7 +751,7 @@ function Invoke-CheckAppRegistrations {
$ScopedAdminUser = @()
$ScopedAdminGroup = @()
$ScopedAdminSP = @()
-
+
[void]$DetailTxtBuilder.AppendLine("############################################################################################################################################")
@@ -783,7 +785,7 @@ function Invoke-CheckAppRegistrations {
############### App Registration Credentials
if ($($item.AppCredentialsDetails | Measure-Object).count -ge 1) {
$ReportingCredentials = foreach ($object in $($item.AppCredentialsDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Type" = $($object.Type)
"DisplayName" = $($object.DisplayName)
"StartDateTime" = $(if ($null -ne $object.StartDateTime) { $object.StartDateTime.ToString() } else { "-" })
@@ -836,7 +838,7 @@ function Invoke-CheckAppRegistrations {
if ($($item.AppOwnerUsers | Measure-Object).count -ge 1) {
$ReportingAppOwnersUser = foreach ($object in $($item.AppOwnerUsers)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"UPN" = $($object.userPrincipalName)
"UPNLink" = "$($object.userPrincipalName)"
"Enabled" = $($object.accountEnabled)
@@ -866,7 +868,7 @@ function Invoke-CheckAppRegistrations {
if ($($item.AppOwnerSPs | Measure-Object).count -ge 1) {
$ReportingAppOwnersSP = foreach ($object in $($item.AppOwnerSPs)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
"Foreign" = $($object.Foreign)
@@ -878,7 +880,7 @@ function Invoke-CheckAppRegistrations {
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine("Owners (Service Principals)")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
- [void]$DetailTxtBuilder.AppendLine(($ReportingAppOwnersSP | format-table -Property DisplayName,Enabled,Foreign,PublisherName,OwnersCount | Out-String))
+ [void]$DetailTxtBuilder.AppendLine(($ReportingAppOwnersSP | format-table -Property DisplayName,Foreign,PublisherName,OwnersCount | Out-String))
$ReportingAppOwnersSP = foreach ($obj in $ReportingAppOwnersSP) {
[pscustomobject]@{
DisplayName = $obj.DisplayNameLink
@@ -891,7 +893,7 @@ function Invoke-CheckAppRegistrations {
}
############### Scoped Admins
-
+
#Wrap to Array and merge
$CloudAppAdminCurrentAppDetails = @($item.CloudAppAdminCurrentAppDetails)
$AppAdminCurrentAppDetails = @($item.AppAdminCurrentAppDetails)
@@ -908,7 +910,7 @@ function Invoke-CheckAppRegistrations {
if ($($EntityDetails.Users | Measure-Object).count -ge 1) {
$ScopedAdminUser = foreach ($object in $($EntityDetails.Users)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role" = $($object.Role)
"Scope" = $($object.Scope)
"AssignmentType" = $($object.AssignmentType)
@@ -943,7 +945,7 @@ function Invoke-CheckAppRegistrations {
if ($($EntityDetails.Groups | Measure-Object).count -ge 1) {
$ScopedAdminGroup = foreach ($object in $($EntityDetails.Groups)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role" = $($object.Role)
"Scope" = $($object.Scope)
"AssignmentType" = $($object.AssignmentType)
@@ -975,7 +977,7 @@ function Invoke-CheckAppRegistrations {
if ($($EntityDetails.SP | Measure-Object).count -ge 1) {
$ScopedAdminSP = foreach ($object in $($EntityDetails.SP)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role" = $($object.Role)
"Scope" = $($object.Scope)
"AssignmentType" = $($object.AssignmentType)
@@ -1022,7 +1024,7 @@ function Invoke-CheckAppRegistrations {
[void]$DetailTxtBuilder.AppendLine(($ReportingAppRoles | format-table | Out-String))
}
-
+
$ObjectDetails=[pscustomobject]@{
"Object Name" = $item.DisplayName
"Object ID" = $item.Id
@@ -1037,7 +1039,7 @@ function Invoke-CheckAppRegistrations {
"Admins (Groups)" = $ScopedAdminGroup
"Admins (ServicePrincipals)" = $ScopedAdminSP
}
-
+
[void]$AllObjectDetailsHTML.Add($ObjectDetails)
}
@@ -1066,7 +1068,7 @@ $ObjectsDetailsHEAD = @'
'@
$AllObjectDetailsHTML = $ObjectsDetailsHEAD + "`n" + $AllObjectDetailsHTML + "`n" + ''
-
+
#Define header
$headerTXT = "************************************************************************************************************************
$Title Enumeration
@@ -1076,13 +1078,13 @@ Execution Warnings = $($ScriptWarningList -join ' / ')
************************************************************************************************************************
"
-$headerHTML = [pscustomobject]@{
+$headerHTML = [pscustomobject]@{
"Executed in Tenant" = "$($CurrentTenant.DisplayName) / ID: $($CurrentTenant.id)"
"Executed at" = "$StartTimestamp "
"Execution Warnings" = $ScriptWarningList -join ' / '
}
-
+
#Define Appendix
$AppendixClientSecrets = "
@@ -1118,6 +1120,7 @@ $headerHtml = @"
$tableOutput | select-object DisplayName,SignInAudience,Enabled,CreationInDays,AppLock,AppRoles,Owners,FederatedCreds,CloudAppAdmins,AppAdmins,SecretsCount,CertsCount,Impact,Likelihood,Risk,Warnings | Export-Csv -Path "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).csv" -NoTypeInformation
}
$DetailOutputTxt | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
+ $AppendixSecretsHTML = ""
$AppsWithSecrets = $AppsWithSecrets | sort-object DisplayName | select-object AppName,Displayname,StartDateTime,EndDateTime,Expired
if (($AppsWithSecrets | Measure-Object).count -ge 1) {
$AppendixClientSecrets | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
@@ -1140,7 +1143,7 @@ $headerHtml = @"
$OutputFormats = if ($Csv) { "CSV,TXT,HTML" } else { "TXT,HTML" }
write-host "[+] Details of $($AllAppRegistrations.count) App Registrations stored in output files ($OutputFormats): $outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
-
+
#Add information to the enumeration summary
$AppLock = 0
$AzureADMyOrg = 0
diff --git a/modules/check_CAPs.psm1 b/modules/check_CAPs.psm1
index e16a498..1665cfe 100644
--- a/modules/check_CAPs.psm1
+++ b/modules/check_CAPs.psm1
@@ -1,10 +1,13 @@
-<#
+<#
.SYNOPSIS
Enumerate CAPs
#>
function Invoke-CheckCaps {
############################## Parameter section ########################
+ # AllGroupsDetails and Users are used by nested helper functions via parent scope
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AllGroupsDetails')]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Users')]
[CmdletBinding()]
Param (
[Parameter(Mandatory=$false)][string]$OutputFolder = ".",
@@ -32,9 +35,9 @@ function Invoke-CheckCaps {
return $false
}
}
-
+
# Function to check if an object is empty, considering nested properties
- function Is-Empty {
+ function Test-IsEmpty {
param ([Object]$Obj)
if ($null -eq $Obj -or $Obj -eq "") {
@@ -43,7 +46,7 @@ function Invoke-CheckCaps {
if ($Obj -is [System.Collections.IEnumerable] -and $Obj -isnot [string]) {
foreach ($item in $Obj) {
- if (-not (Is-Empty $item)) {
+ if (-not (Test-IsEmpty $item)) {
return $false
}
}
@@ -52,7 +55,7 @@ function Invoke-CheckCaps {
if ($Obj -is [PSCustomObject]) {
foreach ($property in $Obj.PSObject.Properties) {
- if (-not (Is-Empty $property.Value)) {
+ if (-not (Test-IsEmpty $property.Value)) {
return $false
}
}
@@ -121,7 +124,7 @@ function Invoke-CheckCaps {
} elseif ($Report -eq "TXT") {
return $ResolvedGUID
}
-
+
}
if ($AllGroupsDetails.ContainsKey($Guid)) {
$ResolvedGUID = $($AllGroupsDetails[$Guid].DisplayName)
@@ -134,12 +137,12 @@ function Invoke-CheckCaps {
}
}
- if ($EnterpriseAppsHT.ContainsKey($Guid)) {
+ if ($EnterpriseAppsHT.ContainsKey($Guid)) {
$ResolvedGUID = $($EnterpriseAppsHT[$Guid])
return $ResolvedGUID
}
-
- if ($NamedLocationsHT.ContainsKey($Guid)) {
+
+ if ($NamedLocationsHT.ContainsKey($Guid)) {
$ResolvedGUID = $($NamedLocationsHT[$Guid].Name)
if ($Report -eq "HTML") {
@@ -149,7 +152,7 @@ function Invoke-CheckCaps {
return $ResolvedGUID
}
}
- if ($RoleTemplatesHT.ContainsKey($Guid)) {
+ if ($RoleTemplatesHT.ContainsKey($Guid)) {
$ResolvedGUID = $($RoleTemplatesHT[$Guid])
return $ResolvedGUID
}
@@ -171,7 +174,7 @@ function Invoke-CheckCaps {
$newIndent = "$Indent "
# Skip empty properties
- if (Is-Empty $value) { continue }
+ if (Test-IsEmpty $value) { continue }
if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) {
$isNestedObject = $false
@@ -185,7 +188,7 @@ function Invoke-CheckCaps {
if ($isNestedObject) {
Write-Output "${Indent}${name}:"
foreach ($item in $value) {
- if (-not (Is-Empty $item)) {
+ if (-not (Test-IsEmpty $item)) {
Write-Output "${newIndent}-"
ConvertTo-Yaml -InputObject $item -Indent "$newIndent " -Report $Report
}
@@ -279,7 +282,7 @@ function Invoke-CheckCaps {
$TargetedLocations = $TargetedLocations -join ", "
}
}
-
+
"#microsoft.graph.ipNamedLocation" {
$NamedLocationType = "IP ranges"
$TargetedLocations = $location.ipRanges.cidrAddress
@@ -300,11 +303,11 @@ function Invoke-CheckCaps {
$MatchingCAPsIncluded = $AllPolicies | Where-Object {
($_.Conditions.Locations.IncludeLocations -contains $location.Id) -or ( ($_.Conditions.Locations.IncludeLocations -contains "AllTrusted") -and $location.isTrusted )
}
-
+
$MatchingCAPsExcluded = $AllPolicies | Where-Object {
($_.Conditions.Locations.ExcludeLocations -contains $location.Id) -or ( ($_.Conditions.Locations.ExcludeLocations -contains "AllTrusted") -and $location.isTrusted )
}
-
+
# Create text values: a comma-separated list of policy display names (if any).
$IncludedCAPsText = if ($MatchingCAPsIncluded) {
($MatchingCAPsIncluded | ForEach-Object { $_.DisplayName }) -join ", "
@@ -322,13 +325,13 @@ function Invoke-CheckCaps {
} else {
""
}
-
+
$ExcludedCAPsTextLinks = if ($MatchingCAPsExcluded) {
( $MatchingCAPsExcluded | ForEach-Object { "$($_.DisplayName)" } ) -join ", "
} else {
""
}
-
+
[pscustomobject]@{
"Id" = $location.Id
"Name" = $location.DisplayName
@@ -401,13 +404,13 @@ function Invoke-CheckCaps {
}
Write-Log -Level Debug -Message "Prepared HT EnterpriseApps $($EnterpriseAppsHT.Count)"
-
+
#Get all role templates to resolve GUIDs
$QueryParameters = @{
'$select' = "Id,Displayname"
}
$RoleTemplates = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/directoryRoleTemplates" -QueryParameters $QueryParameters -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name)
-
+
$RoleTemplatesHT = @{}
foreach ($role in $RoleTemplates ) {
$RoleTemplatesHT[$role.Id] = $role.DisplayName
@@ -477,7 +480,7 @@ function Invoke-CheckCaps {
if ($policy.Conditions.Applications.IncludeApplications -contains "None") {
$IncludedResourcesCount = 0
}
-
+
if ($policy.Conditions.Locations.IncludeLocations -contains "AllTrusted") {
$IncludedNwLocations = "AllTrusted"
$IncludedNwLocationsCount = 1
@@ -536,7 +539,7 @@ function Invoke-CheckCaps {
$ExcludedExternalUsersCount = ($ExcludedExternalUsers -split ',').Count
}
-
+
if ($policy.Conditions.ClientAppTypes -contains "all") {
$ClientAppTypesCount = 0
} else {
@@ -566,11 +569,11 @@ function Invoke-CheckCaps {
}
}
}
-
+
#Get Authcontext
$AuthContextId = $policy.Conditions.Applications.IncludeAuthenticationContextClassReferences
-
+
# Check if there are used Entra role assignments (Tier 0 & 1) which are no in the IncludeRoles
$includedRoleIds = $policy.Conditions.Users.IncludeRoles
$unmatchedRoleCounts = @{}
@@ -581,7 +584,7 @@ function Invoke-CheckCaps {
$roleId = $assignment.RoleDefinitionId
$roleName = $assignment.DisplayName
$roleTier = $assignment.RoleTier
-
+
# Unmatched high-tier roles
if ($includedRoleIds -notcontains $roleId) {
if (-not $unmatchedRoleCounts.ContainsKey($roleName)) {
@@ -597,11 +600,11 @@ function Invoke-CheckCaps {
#Check if there are roles targetd which have a scoped assignment
$ScopedRoles = @()
- $seenScopedRoleIds = @()
+ $seenScopedRoleIds = @()
foreach ($roleId in $includedRoleIds) {
if ($ScopedAssignments.ContainsKey($roleId) -and $seenScopedRoleIds -notcontains $roleId) {
$seenScopedRoleIds += $roleId
-
+
$info = $ScopedAssignments[$roleId]
$ScopedRoles += [PSCustomObject]@{
RoleName = $info.RoleName
@@ -626,11 +629,11 @@ function Invoke-CheckCaps {
$tier0Count = @($unmatchedRoleCounts.Values | Where-Object { $_.Tier -eq 0 }).Count
$tier1Count = @($unmatchedRoleCounts.Values | Where-Object { $_.Tier -eq 1 }).Count
-
+
$parts = @()
if ($tier0Count -gt 0) { $parts += "Tier-0: $tier0Count" }
if ($tier1Count -gt 0) { $parts += "Tier-1: $tier1Count" }
-
+
if ($parts.Count -gt 0) {
$MissingRolesWarning = "missing used roles (" + ($parts -join " / ") + ")"
}
@@ -654,7 +657,7 @@ function Invoke-CheckCaps {
$ExcludedUsersEffective = $policy.Conditions.Users.ExcludeUsers.count + $ExcUsersViaGroups
$ExcludedRolesCount = @($policy.Conditions.Users.ExcludeRoles).Count
$ExcludedNonUserTargets = $ExcludedRolesCount + $ExcludedExternalUsersCount
-
+
#Count condition types for policy complexity checks
$SignInRiskCount = $policy.Conditions.SignInRiskLevels.count
$UserRiskCount = $policy.Conditions.UserRiskLevels.count
@@ -690,7 +693,7 @@ function Invoke-CheckCaps {
$PolicyDeviceCodeFlow = $true
$DeviceCodeFlowWarnings = 0
$ErrorMessages = @()
-
+
if ($policy.State -ne "enabled") {
$ErrorMessages += "is not enabled"
$DeviceCodeFlowWarnings++
@@ -709,7 +712,7 @@ function Invoke-CheckCaps {
}
if ($unmatchedRoleCounts.Count -ne 0) {
$ErrorMessages += $MissingRolesWarning
- $DeviceCodeFlowWarnings++
+ $DeviceCodeFlowWarnings++
}
if ($ExcludedUsersEffective -ge 3) {
$ErrorMessages += "has $ExcludedUsersEffective excluded users (direct or through groups)"
@@ -739,7 +742,7 @@ function Invoke-CheckCaps {
$PolicyLegacyAuth = $true
$LegacyAuthWarnings = 0
$ErrorMessages = @()
-
+
if ($policy.State -ne "enabled") {
$ErrorMessages += "is not enabled"
$LegacyAuthWarnings++
@@ -758,7 +761,7 @@ function Invoke-CheckCaps {
}
if ($unmatchedRoleCounts.Count -ne 0) {
$ErrorMessages += $MissingRolesWarning
- $LegacyAuthWarnings++
+ $LegacyAuthWarnings++
}
if ($ExcludedUsersEffective -ge 3) {
$ErrorMessages += "has $ExcludedUsersEffective excluded users (direct or through groups)"
@@ -773,7 +776,7 @@ function Invoke-CheckCaps {
$ErrorMessages += "has ($additionalConditionTypes) additional condition types"
$LegacyAuthWarnings++
}
-
+
if ($LegacyAuthWarnings -ge 1) {
$warningMessage = "Targeting Legacy Auth but " + ($ErrorMessages -join ", ")
if ([string]::IsNullOrWhiteSpace($WarningPolicy)) {
@@ -789,7 +792,7 @@ function Invoke-CheckCaps {
$PolicyRiskySignIn = $true
$SignInRiskWarnings = 0
$ErrorMessages = @()
-
+
if ($policy.State -ne "enabled") {
$ErrorMessages += "is not enabled"
$SignInRiskWarnings++
@@ -804,7 +807,7 @@ function Invoke-CheckCaps {
}
if ($unmatchedRoleCounts.Count -ne 0) {
$ErrorMessages += $MissingRolesWarning
- $SignInRiskWarnings++
+ $SignInRiskWarnings++
}
if ($ExcludedUsersEffective -ge 3) {
$ErrorMessages += "has $ExcludedUsersEffective excluded users (direct or through groups)"
@@ -819,7 +822,7 @@ function Invoke-CheckCaps {
$ErrorMessages += "has additional ($additionalConditionTypes) condition types"
$SignInRiskWarnings++
}
-
+
if ($SignInRiskWarnings -ge 1) {
$warningMessage = "Targeting risky sign-ins but " + ($ErrorMessages -join ", ")
if ([string]::IsNullOrWhiteSpace($WarningPolicy)) {
@@ -835,7 +838,7 @@ function Invoke-CheckCaps {
$PolicyUserRisk = $true
$UserRiskWarnings = 0
$ErrorMessages = @()
-
+
if ($policy.State -ne "enabled") {
$ErrorMessages += "is not enabled"
$UserRiskWarnings++
@@ -850,7 +853,7 @@ function Invoke-CheckCaps {
}
if ($unmatchedRoleCounts.Count -ne 0) {
$ErrorMessages += $MissingRolesWarning
- $UserRiskWarnings++
+ $UserRiskWarnings++
}
if ($ExcludedUsersEffective -ge 3) {
$ErrorMessages += "has $ExcludedUsersEffective excluded users (direct or through groups)"
@@ -865,7 +868,7 @@ function Invoke-CheckCaps {
$ErrorMessages += "has additional ($additionalConditionTypes) condition types"
$UserRiskWarnings++
}
-
+
if ($UserRiskWarnings -ge 1) {
$warningMessage = "Targeting user risk but " + ($ErrorMessages -join ", ")
if ([string]::IsNullOrWhiteSpace($WarningPolicy)) {
@@ -898,7 +901,7 @@ function Invoke-CheckCaps {
}
if ($unmatchedRoleCounts.Count -ne 0) {
$ErrorMessages += $MissingRolesWarning
- $CombinedRiskWarnings++
+ $CombinedRiskWarnings++
}
if ($ExcludedUsersEffective -ge 3) {
$ErrorMessages += "has $ExcludedUsersEffective excluded users (direct or through groups)"
@@ -929,7 +932,7 @@ function Invoke-CheckCaps {
$PolicyRegSecInfo = $true
$RegisterSecInfosWarnings = 0
$ErrorMessages = @()
-
+
if ($policy.State -ne "enabled") {
$ErrorMessages += "is not enabled"
$RegisterSecInfosWarnings++
@@ -948,13 +951,13 @@ function Invoke-CheckCaps {
}
if ($unmatchedRoleCounts.Count -ne 0) {
$ErrorMessages += $MissingRolesWarning
- $RegisterSecInfosWarnings++
+ $RegisterSecInfosWarnings++
}
if ($ConditionTypeCount -gt 2) {
$ErrorMessages += "has multiple ($ConditionTypeCount) condition types"
$RegisterSecInfosWarnings++
}
-
+
if ($RegisterSecInfosWarnings -ge 1) {
$warningMessage = "Targeting registration of security infos but " + ($ErrorMessages -join ", ")
if ([string]::IsNullOrWhiteSpace($WarningPolicy)) {
@@ -964,13 +967,13 @@ function Invoke-CheckCaps {
}
}
}
-
+
#Check policy for joining or registering devices
if ($policy.Conditions.Applications.IncludeUserActions -contains "urn:user:registerdevice") {
$PolicyRegDevices = $true
$RegisterDevicesInfosWarnings = 0
$ErrorMessages = @()
-
+
if ($policy.State -ne "enabled") {
$ErrorMessages += "is not enabled"
$RegisterDevicesInfosWarnings++
@@ -989,13 +992,13 @@ function Invoke-CheckCaps {
}
if ($unmatchedRoleCounts.Count -ne 0) {
$ErrorMessages += $MissingRolesWarning
- $RegisterDevicesInfosWarnings++
+ $RegisterDevicesInfosWarnings++
}
if ($ConditionTypeCount -gt 1) {
$ErrorMessages += "has multiple ($ConditionTypeCount) condition types"
$RegisterDevicesInfosWarnings++
}
-
+
if ($RegisterDevicesInfosWarnings -ge 1) {
$warningMessage = "Targeting joining or registering devices but " + ($ErrorMessages -join ", ")
if ([string]::IsNullOrWhiteSpace($WarningPolicy)) {
@@ -1011,7 +1014,7 @@ function Invoke-CheckCaps {
$PolicyMfaUser = $true
$UserMfaWarnings = 0
$ErrorMessages = @()
-
+
if ($policy.State -ne "enabled") {
$ErrorMessages += "is not enabled"
$UserMfaWarnings++
@@ -1040,7 +1043,7 @@ function Invoke-CheckCaps {
$ErrorMessages += "has ($ConditionTypeCount) condition types"
$UserMfaWarnings++
}
-
+
if ($UserMfaWarnings -ge 1) {
$warningMessage = "Requires MFA but " + ($ErrorMessages -join ", ")
if ([string]::IsNullOrWhiteSpace($WarningPolicy)) {
@@ -1059,7 +1062,7 @@ function Invoke-CheckCaps {
$PolicyAuthStrength = $true
$AuthStrengthWarnings = 0
$ErrorMessages = @()
-
+
if ($policy.State -ne "enabled") {
$ErrorMessages += "is not enabled"
$AuthStrengthWarnings++
@@ -1078,13 +1081,13 @@ function Invoke-CheckCaps {
}
if ($unmatchedRoleCounts.Count -ne 0) {
$ErrorMessages += $MissingRolesWarning
- $AuthStrengthWarnings++
+ $AuthStrengthWarnings++
}
if ($ConditionTypeCount -gt 1) {
$ErrorMessages += "has multiple ($ConditionTypeCount) condition types"
$AuthStrengthWarnings++
}
-
+
if ($AuthStrengthWarnings -ge 1) {
$warningMessage = "Requires Authentication Strength (no Auth Context) but " + ($ErrorMessages -join ", ")
if ([string]::IsNullOrWhiteSpace($WarningPolicy)) {
@@ -1096,7 +1099,7 @@ function Invoke-CheckCaps {
}
#General Policy checks
-
+
#Check if the role includes roles but scope assignment exist for the role
if ($ScopedRolesCount -gt 0) {
if (-not [string]::IsNullOrWhiteSpace($WarningPolicy)) {
@@ -1118,7 +1121,7 @@ function Invoke-CheckCaps {
$AuthStrengthId = [string]$policy.GrantControls.AuthenticationStrength.Id
}
}
-
+
$ConditionalAccessPolicies.Add([PSCustomObject]@{
Id = $policy.Id
@@ -1205,7 +1208,7 @@ function Invoke-CheckCaps {
if (!$PolicyAuthStrength) {
$Warnings += "No policy enforcing Authentication Strength (e.g., phishing-resistant MFA) for admins was found!"
}
-
+
if ($Warnings.count -ge 1) {
# Correct way to format warnings into HTML list items
$MissingPolicies = ($Warnings | ForEach-Object { "
$_" }) -join "`n"
@@ -1236,7 +1239,7 @@ $MissingPolicies
$HtmlGrantControls = @()
$MissingRoles = @()
$ScopedRoles = @()
-
+
[void]$DetailTxtBuilder.AppendLine("############################################################################################################################################")
$ReportingCapInfo = [pscustomobject]@{
@@ -1244,7 +1247,7 @@ $MissingPolicies
"ID" = $($item.Id)
"State" = $($item.State)
}
-
+
#Sometimes even $item.CreatedDateTime is $null
if ($null -ne $item.CreatedDateTime) {
$ReportingCapInfo | Add-Member -NotePropertyName Created -NotePropertyValue $item.CreatedDateTime.ToString()
@@ -1261,7 +1264,7 @@ $MissingPolicies
if ($matchingWarnings -ne "") {
$ReportingCapInfo | Add-Member -NotePropertyName Warnings -NotePropertyValue $matchingWarnings
}
-
+
[void]$DetailTxtBuilder.AppendLine(($ReportingCapInfo | Format-List | Out-String))
@@ -1270,7 +1273,7 @@ $MissingPolicies
if ($policy.MissingRoles.count -ge 1) {
$MissingRoles = foreach ($object in $($policy.MissingRoles)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"RoleName" = $($object.RoleName)
"RoleTier" = $($object.RoleTier)
"AssignmentsLink" = "$($object.Assignments)"
@@ -1289,14 +1292,14 @@ $MissingPolicies
"Assignments" = $($object.AssignmentsLink)
}
}
-
- }
-
+
+ }
+
############### Missing Roles
if ($policy.ScopedRoles.count -ge 1) {
$ScopedRoles = foreach ($object in $($policy.ScopedRoles)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"RoleName" = $($object.RoleName)
"RoleTier" = $($object.RoleTier)
"AssignmentsScopedLink" = "$($object.Assignments)"
@@ -1315,8 +1318,8 @@ $MissingPolicies
"AssignmentsScoped" = $($object.AssignmentsScopedLink)
}
}
- }
-
+ }
+
# Convert the raw CAP JSON to YAML, enriching it with HTTP links.
if ($null -ne $item.Conditions) {
@@ -1368,9 +1371,9 @@ $MissingPolicies
"Targeted Roles With Scoped Assignments" = $ScopedRoles
"Conditions" = $HtmlConditions
"Session Controls" = $HtmlSessionControls
- "Grant Controls" = $HtmlGrantControls
+ "Grant Controls" = $HtmlGrantControls
}
-
+
[void]$AllObjectDetailsHTML.Add($ObjectDetails)
}
@@ -1380,7 +1383,7 @@ $MissingPolicies
if ($AllPoliciesCount -gt 0) {
$mainTable = $tableOutput | select-object -Property @{Label="DisplayName"; Expression={$_.DisplayNameLink}},State,IncResources,ExcResources,AuthContext,IncUsers,ExcUsers,IncGroups,IncUsersViaGroups,ExcGroups,ExcUsersViaGroups,IncRoles,ExcRoles,IncExternals,ExcExternals,DeviceFilter,IncPlatforms,ExcPlatforms,SignInRisk,UserRisk,IncNw,ExcNw,AppTypes,AuthFlow,UserActions,GrantControls,SessionControls,SignInFrequency,SignInFrequencyInterval,AuthStrength,Warnings
- $mainTableJson = $mainTable | ConvertTo-Json -Depth 10 -Compress
+ $mainTableJson = $mainTable | ConvertTo-Json -Depth 10 -Compress
} else {
#Define an empty JSON object to make the HTML report loading
$mainTableJson = "[{}]"
@@ -1437,14 +1440,14 @@ $headerHtml = @"
$Title Overview
"@
-
+
#Write TXT and CSV files
$headerTXT | Out-File -Width 768 -FilePath "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt"
- if ($AllPoliciesCount -gt 0 -and $Csv) {
+ if ($AllPoliciesCount -gt 0 -and $Csv) {
$tableOutput | select-object DisplayName,State,IncResources,ExcResources,AuthContext,IncUsers,ExcUsers,IncGroups,ExcGroups,IncRoles,ExcRoles,IncExternals,ExcExternals,DeviceFilter,IncPlatforms,ExcPlatforms,SignInRisk,UserRisk,IncNw,ExcNw,AppTypes,AuthFlow,UserActions,GrantControls,SessionControls,SignInFrequency,SignInFrequencyInterval,AuthStrength,Warnings | Export-Csv -Path "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).csv" -NoTypeInformation
}
$tableOutput | format-table -Property DisplayName,State,IncResources,ExcResources,AuthContext,IncUsers,ExcUsers,IncGroups,ExcGroups,IncRoles,ExcRoles,IncExternals,ExcExternals,DeviceFilter,IncPlatforms,ExcPlatforms,SignInRisk,UserRisk,IncNw,ExcNw,AppTypes,AuthFlow,UserActions,GrantControls,SessionControls,SignInFrequency,SignInFrequencyInterval,AuthStrength,Warnings | Out-File -Width 768 -FilePath "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
- if ($Warnings.count -ge 1) {$Warnings | Out-File -Width 768 -FilePath "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append}
+ if ($Warnings.count -ge 1) {$Warnings | Out-File -Width 768 -FilePath "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append}
$DetailOutputTxt | Out-File -FilePath "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
@@ -1464,7 +1467,7 @@ $headerHtml = @"
$OutputFormats = if ($Csv) { "CSV,TXT,HTML" } else { "TXT,HTML" }
write-host "[+] Details of $AllPoliciesCount policies stored in output files ($OutputFormats): $outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
-
+
#Add information to the enumeration summary
$GlobalAuditSummary.ConditionalAccess.Count = $AllPoliciesCount
$EnabledCount = 0
@@ -1473,7 +1476,7 @@ $headerHtml = @"
$EnabledCount ++
}
}
- $GlobalAuditSummary.ConditionalAccess.Enabled = $EnabledCount
+ $GlobalAuditSummary.ConditionalAccess.Enabled = $EnabledCount
#Convert to Hashtable for faster searches
$AllCapsHT = @{}
@@ -1481,6 +1484,6 @@ $headerHtml = @"
$AllCapsHT[$item.Id] = $item
}
Return $AllCapsHT
-
+
}
diff --git a/modules/check_EnterpriseApps.psm1 b/modules/check_EnterpriseApps.psm1
index 17c278b..f0828e0 100644
--- a/modules/check_EnterpriseApps.psm1
+++ b/modules/check_EnterpriseApps.psm1
@@ -1,11 +1,11 @@
-<#
+<#
.SYNOPSIS
Enumerate Enterprise Applications (including: API Permission, Source Tenant, Groups, Roles).
.DESCRIPTION
This script will enumerate all Enterprise Applications (including: API Permission, Source Tenant, Groups, Roles).
By default, MS applications are filtered out.
-
+
#>
function Invoke-CheckEnterpriseApps {
@@ -95,7 +95,7 @@ function Invoke-CheckEnterpriseApps {
if ($null -ne $item.AppRoles) {
$role = $item.AppRoles | Where-Object {$_.AllowedMemberTypes -contains "Application"} | select-object id,DisplayName,Value,Description
foreach ($permission in $role) {
- [PSCustomObject]@{
+ [PSCustomObject]@{
AppID = $item.Id
AppName = $item.DisplayName
ApiPermissionId = $permission.id
@@ -103,7 +103,7 @@ function Invoke-CheckEnterpriseApps {
ApiPermissionDisplayName = $permission.DisplayName
ApiPermissionDescription = $permission.Description
ApiPermissionCategorization = Get-APIPermissionCategory -InputPermission $permission.id -PermissionType "application"
- }
+ }
}
}
}
@@ -152,16 +152,16 @@ function Invoke-CheckEnterpriseApps {
id = $app.appId
lastSignIn = if ($app.lastSignInActivity.lastSignInDateTime) {$app.lastSignInActivity.lastSignInDateTime} else { "-" }
lastSignInDays = if ($app.lastSignInActivity.lastSignInDateTime) { (New-TimeSpan -Start $app.lastSignInActivity.lastSignInDateTime).Days } else { "-" }
-
+
lastSignInAppAsClient = if ($app.applicationAuthenticationClientSignInActivity.lastSignInDateTime) {$app.applicationAuthenticationClientSignInActivity.lastSignInDateTime} else { "-" }
lastSignInAppAsClientDays = if ($app.applicationAuthenticationClientSignInActivity.lastSignInDateTime) { (New-TimeSpan -Start $app.applicationAuthenticationClientSignInActivity.lastSignInDateTime).Days } else { "-" }
-
+
lastSignInAppAsResource = if ($app.applicationAuthenticationResourceSignInActivity.lastSignInDateTime) {$app.applicationAuthenticationResourceSignInActivity.lastSignInDateTime} else { "-" }
lastSignInAppAsResourceDays = if ($app.applicationAuthenticationResourceSignInActivity.lastSignInDateTime) { (New-TimeSpan -Start $app.applicationAuthenticationResourceSignInActivity.lastSignInDateTime).Days } else { "-" }
-
+
lastSignInDelegatedAsClient = if ($app.delegatedClientSignInActivity.lastSignInDateTime) {$app.delegatedClientSignInActivity.lastSignInDateTime} else { "-" }
lastSignInDelegatedAsClientDays = if ($app.delegatedClientSignInActivity.lastSignInDateTime) { (New-TimeSpan -Start $app.delegatedClientSignInActivity.lastSignInDateTime).Days } else { "-" }
-
+
lastSignInDelegatedAsResource = if ($app.delegatedResourceSignInActivity.lastSignInDateTime) {$app.delegatedResourceSignInActivity.lastSignInDateTime} else { "-" }
lastSignInDelegatedAsResourceDays = if ($app.delegatedResourceSignInActivity.lastSignInDateTime) { (New-TimeSpan -Start $app.delegatedResourceSignInActivity.lastSignInDateTime).Days } else { "-" }
}
@@ -290,14 +290,14 @@ function Invoke-CheckEnterpriseApps {
#Enumerate all AppRoles configured (only of the apps in scope)
$AppRoles = [System.Collections.ArrayList]::new()
-
+
foreach ($app in $EnterpriseApps) {
if (-not $AppRolesAssignedToRaw.ContainsKey($app.Id)) { continue }
-
+
$userRoles = $app.AppRoles
-
+
foreach ($assignment in $AppRolesAssignedToRaw[$app.Id]) {
-
+
# Handle default access assignments
if ($assignment.appRoleId -eq '00000000-0000-0000-0000-000000000000') {
[void]$AppRoles.Add([PSCustomObject]@{
@@ -314,10 +314,10 @@ function Invoke-CheckEnterpriseApps {
})
continue
}
-
+
# Handle explicitly assigned roles
$matchedRole = $userRoles | Where-Object { $_.Id -eq $assignment.appRoleId }
-
+
if ($matchedRole) {
foreach ($role in $matchedRole) {
[void]$AppRoles.Add([PSCustomObject]@{
@@ -379,12 +379,11 @@ function Invoke-CheckEnterpriseApps {
$WarningsHighPermission = $null
$WarningsDangerousPermission = $null
$WarningsMediumPermission = $null
- $Owners = $null
$AppCredentials = @()
$OwnerUserDetails = @()
$OwnerSPDetails = @()
$AppRegObjectId = ""
-
+
# Display status based on the objects numbers (slightly improves performance)
if ($ProgressCounter % $StatusUpdateInterval -eq 0 -or $ProgressCounter -eq $EnterpriseAppsCount) {
@@ -488,19 +487,19 @@ function Invoke-CheckEnterpriseApps {
AppRoleAssignmentType = $role.AppRoleAssignmentType
AppRoleDescription = $description
}
-
+
# Add the new object to the array
$MatchingAppRoles += $newRole
}
}
-
+
# Enumerate all roles including scope the app is assigned to (note: Get-MgBetaServicePrincipalMemberOf do not return custom roles or scoped roles)
$MatchingRoles = $TenantRoleAssignments[$item.Id]
$AppEntraRoles = @()
- $AppEntraRoles = foreach ($Role in $MatchingRoles) {
- [PSCustomObject]@{
+ $AppEntraRoles = foreach ($Role in $MatchingRoles) {
+ [PSCustomObject]@{
Type = "Roles"
DisplayName = $Role.DisplayName
Enabled = $Role.IsEnabled
@@ -566,15 +565,15 @@ function Invoke-CheckEnterpriseApps {
'$select' = "DisplayName"
}
#Set odata.metadata=none to avoid having metadata in the response
- $headers = @{
- 'Accept' = 'application/json;odata.metadata=none'
+ $headers = @{
+ 'Accept' = 'application/json;odata.metadata=none'
}
$ApiAppDisplayNameCache[$permission.ResourceId] = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/servicePrincipals/$($permission.ResourceId)" -QueryParameters $QueryParameters -AdditionalHeaders $headers -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name)
}
# Split the Scope field by spaces to get individual permissions. Ignores whitespece at the start of the string
$scopes = $permission.Scope.Trim() -split " "
-
+
if ($permission.ConsentType -eq "Principal") {
$principal = $permission.PrincipalId
} else {
@@ -608,8 +607,8 @@ function Invoke-CheckEnterpriseApps {
$count = ($DelegatedPermissionDetails | Where-Object { $_.ApiPermissionCategorization -eq $severity } | Measure-Object ).Count
$DelegateApiPermssionCount[$severity] = $count
}
-
-
+
+
#Get all groups where the SP is member of
$GroupMember = [System.Collections.ArrayList]::new()
@@ -632,7 +631,7 @@ function Invoke-CheckEnterpriseApps {
Get-GroupDetails -Group $Group -AllGroupsDetails $AllGroupsDetails
}
-
+
#Get application owned objects (can own groups or applications)
$OwnedApplications = [System.Collections.ArrayList]::new()
$OwnedGroups = [System.Collections.ArrayList]::new()
@@ -673,8 +672,8 @@ function Invoke-CheckEnterpriseApps {
}
}
}
-
- $OwnedGroups = foreach ($Group in $OwnedGroups) {
+
+ $OwnedGroups = foreach ($Group in $OwnedGroups) {
Get-GroupDetails -Group $Group -AllGroupsDetails $AllGroupsDetails
}
@@ -718,7 +717,7 @@ function Invoke-CheckEnterpriseApps {
$OwnedApplicationsCount = $OwnedApplications.count
$OwnedSPCount = $OwnedSP.count
-
+
#Check if sp has configured credentials
$AppCredentialsSecrets = foreach ($creds in $item.PasswordCredentials) {
@@ -748,14 +747,11 @@ function Invoke-CheckEnterpriseApps {
}
########################################## SECTION: RISK RATING AND WARNINGS ##########################################
-
-
+
+
# Check if it the Entra Connect Sync App
if ($item.DisplayName -match "ConnectSyncProvisioning_") {
- $EntraConnectApp = $true
$Warnings += "Entra Connect Sync Application!"
- } else {
- $EntraConnectApp = $false
}
# Check if the SP has credentials defined
@@ -771,7 +767,7 @@ function Invoke-CheckEnterpriseApps {
#If not set corresponding SP object ID
$AppRegObjectId = $AppRegistrations[$($item.AppId)].id
-
+
} else {
$ForeignTenant = $true
}
@@ -824,7 +820,7 @@ function Invoke-CheckEnterpriseApps {
}
}
}
-
+
$OwnersCount = $OwnerUserDetails.count + $OwnerSPDetails.count
#Check owners of the SP.
if ($OwnersCount -ge 1) {
@@ -836,7 +832,7 @@ function Invoke-CheckEnterpriseApps {
$Warnings += "SP with owner (unknown AppLock)!"
} else {
$AppLockConfiguration = $AppRegistrations[$($item.AppId)].ServicePrincipalLockConfiguration
-
+
#App instance property lock can be completely disabled or more granular
if ($AppLockConfiguration.IsEnabled -ne $true -or ($AppLockConfiguration.AllProperties -ne $true -and $AppLockConfiguration.credentialsWithUsageVerify -ne $true)) {
$LikelihoodScore += $SPLikelihoodScore["NoAppLock"]
@@ -846,39 +842,39 @@ function Invoke-CheckEnterpriseApps {
}
}
}
-
+
#Increase likelihood for each owner (user) SP ownership is calculated in the post-processing part
- $LikelihoodScore += $OwnerUserDetails.count * $SPLikelihoodScore["Owners"]
+ $LikelihoodScore += $OwnerUserDetails.count * $SPLikelihoodScore["Owners"]
#Increase impact for each App role
$AppRolesCount = ($MatchingAppRoles | Measure-Object).count
if ($AppRolesCount -ge 1) {
- $ImpactScore += $AppRolesCount * $SPImpactScore["AppRole"]
+ $ImpactScore += $AppRolesCount * $SPImpactScore["AppRole"]
}
- #Increase impact if App Roles needs to be assigned
+ #Increase impact if App Roles needs to be assigned
if ($item.AppRoleAssignmentRequired) {
- $ImpactScore += $SPImpactScore["AppRoleRequired"]
+ $ImpactScore += $SPImpactScore["AppRoleRequired"]
}
#If SP owns App Registration
if ($OwnedApplicationsCount -ge 1) {
- $Warnings += "SP owns $OwnedApplicationsCount App Registrations!"
+ $Warnings += "SP owns $OwnedApplicationsCount App Registrations!"
}
#If SP owns another SP
if ($OwnedSPCount -ge 1) {
- $Warnings += "SP owns $OwnedSPCount Enterprise Applications!"
+ $Warnings += "SP owns $OwnedSPCount Enterprise Applications!"
}
-
+
#Check if it is one of the MS default SPs
- if ($GLOBALMsTenantIds -contains $item.AppOwnerOrganizationId -or $item.DisplayName -eq "O365 LinkedIn Connection" -and $item.DisplayName -ne "P2P Server") {
+ if ($GLOBALMsTenantIds -contains $item.AppOwnerOrganizationId -or $item.DisplayName -eq "O365 LinkedIn Connection" -and $item.DisplayName -ne "P2P Server") {
$DefaultMS = $true
} else {
$DefaultMS = $false
}
-
+
#Process group memberships
if (($GroupMember | Measure-Object).count -ge 1) {
@@ -945,7 +941,7 @@ function Invoke-CheckEnterpriseApps {
$Warnings += $EntraRolesProcessedDetails.Warning
$ImpactScore += $EntraRolesProcessedDetails.ImpactScore
}
-
+
#If SP owns groups
if (($OwnedGroups | Measure-Object).count -ge 1) {
@@ -1041,7 +1037,7 @@ function Invoke-CheckEnterpriseApps {
if ($severities.Count -gt 0) {
$lastIndex = $severities.Count - 1
$last = $severities[$lastIndex]
-
+
if ($severities.Count -gt 1) {
$first = $severities[0..($lastIndex - 1)] -join ", "
$joined = "$first and $last"
@@ -1107,7 +1103,7 @@ function Invoke-CheckEnterpriseApps {
if ($severities.Count -gt 0) {
$lastIndex = $severities.Count - 1
$last = $severities[$lastIndex]
-
+
if ($severities.Count -gt 1) {
$first = $severities[0..($lastIndex - 1)] -join ", "
$joined = "$first and $last"
@@ -1135,7 +1131,7 @@ function Invoke-CheckEnterpriseApps {
''
}
- # if
+ # if
if ($AppsignInData.lastSignInDays) {
$LastSignInDays = $AppsignInData.lastSignInDays
} else {
@@ -1145,7 +1141,7 @@ function Invoke-CheckEnterpriseApps {
$AzureRolesEffective = $AzureRolesDirect + $AzureRolesThroughGroupMembership + $AzureRolesThroughGroupOwnership
#Write custom object
- $SPInfo = [PSCustomObject]@{
+ $SPInfo = [PSCustomObject]@{
Id = $item.Id
DisplayName = $item.DisplayName
Enabled = $item.accountEnabled
@@ -1194,7 +1190,7 @@ function Invoke-CheckEnterpriseApps {
AppRoles = ($MatchingAppRoles | Measure-Object).count
AppRolesDetails = $MatchingAppRoles
ApiDelegated = $DelegatedPermissionDetailsUnique
- ApiDelegatedDetails = $DelegatedPermissionDetails
+ ApiDelegatedDetails = $DelegatedPermissionDetails
ApiDelegatedDangerous = $DelegateApiPermssionCount.Dangerous
ApiDelegatedHigh = $DelegateApiPermssionCount.High
ApiDelegatedMedium = $DelegateApiPermssionCount.Medium
@@ -1216,7 +1212,7 @@ function Invoke-CheckEnterpriseApps {
########################################## SECTION: POST-PROCESSING ##########################################
write-host "[*] Post-processing SP ownership relation with other apps"
-
+
#Process indirect App ownerships (SP->AppReg->SP) (take over Impact, inherit likelihood)
$SPOwningApps = $AllServicePrincipal | Where-Object { $_.AppOwn -ge 1 }
@@ -1224,10 +1220,10 @@ function Invoke-CheckEnterpriseApps {
# For each object which owns an App registration
foreach ($SpObject in $SPOwningApps) {
-
+
# For each owned App Registration
foreach ($AppRegistration in $SpObject.OwnedApplicationsDetails) {
-
+
#For each corresponding SP object of the App Registration
foreach ($OwnedSP in $AllServicePrincipal | Where-Object { $_.AppId -eq $AppRegistration.AppId }) {
@@ -1256,7 +1252,7 @@ function Invoke-CheckEnterpriseApps {
Write-Log -Level Debug -Message "Number of ownerships SP->SP: $($SPOwningSPs.count)"
#For each object which owns an App registration
foreach ($SpOwnerObject in $SPOwningSPs) {
-
+
# For each owned App Registration
foreach ($OwnedSPObject in $SpOwnerObject.OwnedSPDetails) {
@@ -1289,7 +1285,7 @@ function Invoke-CheckEnterpriseApps {
#Define output of the main table
$tableOutput = $AllServicePrincipal | Sort-Object -Property risk -Descending | select-object DisplayName,DisplayNameLink,AppRoleRequired,PublisherName,DefaultMS,Foreign,Enabled,Inactive,SAML,LastSignInDays,CreationInDays,AppRoles,GrpMem,GrpOwn,AppOwn,SpOwn,EntraRoles,EntraMaxTier,Owners,Credentials,AzureRoles,AzureMaxTier,ApiDangerous, ApiHigh, ApiMedium, ApiLow, ApiMisc,ApiDelegated,ApiDelegatedDangerous,ApiDelegatedHigh,ApiDelegatedMedium,ApiDelegatedLow,ApiDelegatedMisc,Impact,Likelihood,Risk,Warnings
-
+
#Define the apps to be displayed in detail and sort them by risk score
$details = $AllServicePrincipal | Sort-Object Risk -Descending
@@ -1350,7 +1346,7 @@ function Invoke-CheckEnterpriseApps {
}
[void]$DetailTxtBuilder.AppendLine(($ReportingEntAppInfo| Select-Object $TxtReportProps | Out-String))
-
+
############### Last Sing-Ins
$lastSignIn = if ($($item.AppsignInData.lastSignIn) -and $($item.AppsignInData.lastSignIn) -ne "-") {"$($item.AppsignInData.lastSignIn) ($($item.AppsignInData.lastSignInDays) days ago)"} else {"-"}
$lastSignInAppAsClient = if ($($item.AppsignInData.lastSignInAppAsClient) -and $($item.AppsignInData.lastSignInAppAsClient) -ne "-") {"$($item.AppsignInData.lastSignInAppAsClient) ($($item.AppsignInData.lastSignInAppAsClientDays) days ago)"} else {"-"}
@@ -1362,8 +1358,8 @@ function Invoke-CheckEnterpriseApps {
"Last sign-in as application (client)" = $lastSignInAppAsClient
"Last sign-in as application (resource)" = $lastSignInAppAsResource
"Last sign-in delegated (client)" = $lastSignInDelegatedAsClient
- "Last sign-in delegated (resource)" = $lastSignInDelegatedAsResource
- }
+ "Last sign-in delegated (resource)" = $lastSignInDelegatedAsResource
+ }
[void]$DetailTxtBuilder.AppendLine("-----------------------------------------------------------------")
[void]$DetailTxtBuilder.AppendLine("Last Sign-Ins Details")
[void]$DetailTxtBuilder.AppendLine("-----------------------------------------------------------------")
@@ -1378,7 +1374,7 @@ function Invoke-CheckEnterpriseApps {
############### Entra Roles
if ($($item.EntraRoleDetails | Measure-Object).count -ge 1) {
$ReportingRoles = foreach ($object in $($item.EntraRoleDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role name" = $($object.DisplayName)
"Tier Level" = $($object.RoleTier)
"Privileged" = $($object.isPrivileged)
@@ -1392,12 +1388,12 @@ function Invoke-CheckEnterpriseApps {
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine(($ReportingRoles | format-table | Out-String))
}
-
+
############### Azure Roles
if ($($item.AzureRoleDetails | Measure-Object).count -ge 1) {
$ReportingAzureRoles = foreach ($object in $($item.AzureRoleDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role name" = $($object.RoleName)
"RoleType" = $($object.RoleType)
"Tier Level" = $($object.RoleTier)
@@ -1416,7 +1412,7 @@ function Invoke-CheckEnterpriseApps {
############### Group Owner
if ($($item.GroupOwner | Measure-Object).count -ge 1) {
$ReportingGroupOwner = foreach ($object in $($item.GroupOwner)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
"SecurityEnabled" = $($object.SecurityEnabled)
@@ -1445,12 +1441,12 @@ function Invoke-CheckEnterpriseApps {
Warnings = $obj.Warnings
}
}
- }
+ }
############### App owner
if ($($item.OwnedApplicationsDetails | Measure-Object).count -ge 1) {
$ReportingAppOwner = foreach ($object in $($item.OwnedApplicationsDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
}
@@ -1470,10 +1466,10 @@ function Invoke-CheckEnterpriseApps {
############### SP owner
if ($($item.OwnedSPDetails | Measure-Object).count -ge 1) {
$ReportingSPOwner = foreach ($object in $($item.OwnedSPDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
- "Foreign" = $($object.Foreign)
+ "Foreign" = $($object.Foreign)
"Impact" = $($object.Impact)
}
}
@@ -1490,11 +1486,11 @@ function Invoke-CheckEnterpriseApps {
}
}
}
-
+
############### Group Member
if ($($item.GroupMember | Measure-Object).count -ge 1) {
$ReportingGroupMember = foreach ($object in $($item.GroupMember)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
"SecurityEnabled" = $($object.SecurityEnabled)
@@ -1523,12 +1519,12 @@ function Invoke-CheckEnterpriseApps {
Warnings = $obj.Warnings
}
}
- }
+ }
############### Enterprise Application Credentials
if ($($item.AppCredentialsDetails | Measure-Object).count -ge 1) {
$ReportingCredentials = foreach ($object in $($item.AppCredentialsDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Type" = $($object.Type)
"DisplayName" = $($object.DisplayName)
"StartDateTime" = $(if ($null -ne $object.StartDateTime) { $object.StartDateTime.ToString() } else { "-" })
@@ -1540,7 +1536,7 @@ function Invoke-CheckEnterpriseApps {
[void]$DetailTxtBuilder.AppendLine("Enterprise Application Credentials")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine(($ReportingCredentials | Out-String))
- }
+ }
############### App Roles
if ($($item.AppRolesDetails | Measure-Object).count -ge 1) {
@@ -1569,7 +1565,7 @@ function Invoke-CheckEnterpriseApps {
Write-Log -Level Debug -Message "Unknown AppRoleAssignmentType: $($object.AppRoleAssignmentType) / object: $($object.AppRoleMemberId)"
}
}
- [pscustomobject]@{
+ [pscustomobject]@{
"AppRoleClaim" = $($object.AppRoleClaim)
"AppRoleName" = $($object.AppRoleName)
"RoleEnabled" = $($object.RoleEnabled)
@@ -1583,7 +1579,7 @@ function Invoke-CheckEnterpriseApps {
[void]$DetailTxtBuilder.AppendLine("Assigned App Roles")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine(($ReportingAppRoles | format-table -Property AppRoleName,RoleEnabled,AppRoleAssignmentType,AppRoleMember | Out-String))
-
+
#Rebuild for HTML report
$ReportingAppRoles = foreach ($obj in $ReportingAppRoles) {
[pscustomobject]@{
@@ -1600,7 +1596,7 @@ function Invoke-CheckEnterpriseApps {
if ($($item.OwnerUserDetails | Measure-Object).count -ge 1 -or $($item.OwnerSPDetails | Measure-Object).count -ge 1) {
if ($($item.OwnerUserDetails | Measure-Object).count -ge 1) {
$ReportingAppOwnersUser = foreach ($object in $($item.OwnerUserDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"UPN" = $($object.UPN)
"UPNLink" = "$($object.UPN)"
"Enabled" = $($object.Enabled)
@@ -1614,7 +1610,7 @@ function Invoke-CheckEnterpriseApps {
[void]$DetailTxtBuilder.AppendLine("Owners (Users)")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine(($ReportingAppOwnersUser | format-table -Property UPN,Enabled,Type,OnPremSync,Department,JobTitle | Out-String))
-
+
#Rebuild for HTML report
$ReportingAppOwnersUser = foreach ($obj in $ReportingAppOwnersUser) {
[pscustomobject]@{
@@ -1630,7 +1626,7 @@ function Invoke-CheckEnterpriseApps {
if ($($item.OwnerSPDetails | Measure-Object).count -ge 1) {
$ReportingAppOwnersSP = foreach ($object in $($item.OwnerSPDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
"Enabled" = $($object.Enabled)
@@ -1651,12 +1647,12 @@ function Invoke-CheckEnterpriseApps {
}
}
}
- }
+ }
############### API permission
if ($($item.AppApiPermission | Measure-Object).count -ge 1) {
$ReportingAPIPermission = foreach ($object in $($item.AppApiPermission)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"API" = $($object.ApiName)
"Category" = $($object.ApiPermissionCategorization)
"Permission" = $($object.ApiPermission)
@@ -1668,16 +1664,16 @@ function Invoke-CheckEnterpriseApps {
[void]$DetailTxtBuilder.AppendLine("API Permission (Application)")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine(($ReportingAPIPermission | Out-String))
- }
+ }
############### API Delegated Permissions
if ($($item.ApiDelegatedDetails | Measure-Object).count -ge 1) {
$ReportingDelegatedApiPermission = foreach ($object in $($item.ApiDelegatedDetails)) {
-
+
$userDetails = $AllUsersBasicHT[$object.Principal]
-
+
# Check if a matching user was found
if ($userDetails) {
$PrincipalUpn = $userDetails.UserPrincipalName
@@ -1688,7 +1684,7 @@ function Invoke-CheckEnterpriseApps {
$PrincipalUpnLink = $object.Principal
}
- [pscustomobject]@{
+ [pscustomobject]@{
"APIName" = $object.APIName
"Permission" = $object.Scope
"Categorization" = $object.ApiPermissionCategorization
@@ -1704,7 +1700,7 @@ function Invoke-CheckEnterpriseApps {
[void]$DetailTxtBuilder.AppendLine(($ReportingDelegatedApiPermission | format-table APIName,Permission,Categorization,ConsentType,Principal | Out-String))
$ReportingDelegatedApiPermission = foreach ($obj in $ReportingDelegatedApiPermission) {
- [pscustomobject]@{
+ [pscustomobject]@{
"APIName" = $obj.APIName
"Permission" = $obj.Permission
"Categorization" = $obj.Categorization
@@ -1733,13 +1729,13 @@ function Invoke-CheckEnterpriseApps {
"API Permission (Application)" = $ReportingAPIPermission
"API Permission (Delegated)" = $ReportingDelegatedApiPermission
}
-
+
[void]$AllObjectDetailsHTML.Add($ObjectDetails)
}
$DetailOutputTxt = $DetailTxtBuilder.ToString()
-
+
write-host "[*] Writing log files"
write-host
@@ -1818,16 +1814,16 @@ $headerHtml = @"
$DetailOutputTxt | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
$AppendixHeaderTXT | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
$ApiPermissionReference | Format-Table -AutoSize | Out-File -Width 512 "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
-
+
#Write HTML
$ApiPermissionReferenceHTML += $ApiPermissionReference | ConvertTo-Html -Fragment -PreContent "Appendix: Used API Permission Reference
"
$PostContentCombined = $GLOBALJavaScript + "`n" + $ApiPermissionReferenceHTML
$Report = ConvertTo-HTML -Body "$headerHTML $mainTableHTML" -Title "$Title Enumeration" -Head ($global:GLOBALReportManifestScript + $global:GLOBALCss) -PostContent $PostContentCombined -PreContent $AllObjectDetailsHTML
$Report | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).html"
-
+
$OutputFormats = if ($Csv) { "CSV,TXT,HTML" } else { "TXT,HTML" }
write-host "[+] Details of $EnterpriseAppsCount Enterprise Applications stored in output files ($OutputFormats): $outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
-
+
#Add information to the enumeration summary
$ForeignCount = 0
$CredentialCount = 0
@@ -1855,7 +1851,7 @@ $headerHtml = @"
$GlobalAuditSummary.EnterpriseApps.Count = $EnterpriseAppsCount
$GlobalAuditSummary.EnterpriseApps.Foreign = $ForeignCount
$GlobalAuditSummary.EnterpriseApps.Credentials = $CredentialCount
-
+
$GlobalAuditSummary.EnterpriseApps.ApiCategorization.Dangerous = $AppApiDangerous
$GlobalAuditSummary.EnterpriseApps.ApiCategorization.High = $AppApiHigh
diff --git a/modules/check_Groups.psm1 b/modules/check_Groups.psm1
index 4b852ca..716f132 100644
--- a/modules/check_Groups.psm1
+++ b/modules/check_Groups.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Enumerates groups and evaluates their configurations, ownership, roles, and risk posture.
#>
@@ -32,13 +32,13 @@ function Invoke-CheckGroups {
#Function to check if SP is foreign.
function CheckSP {
param($Object)
-
+
$sp = $AllSPBasicHT[$Object.id]
if (-not $sp) { return }
-
+
$ForeignTenant = ($sp.servicePrincipalType -ne "ManagedIdentity" -and $sp.AppOwnerOrganizationId -ne $CurrentTenant.id)
$DefaultMS = ($sp.servicePrincipalType -ne "ManagedIdentity" -and $GLOBALMsTenantIds -contains $sp.AppOwnerOrganizationId)
-
+
[PSCustomObject]@{
Id = $sp.id
DisplayName = $sp.displayName
@@ -47,7 +47,7 @@ function Invoke-CheckGroups {
SPType = $sp.servicePrincipalType
DefaultMS = $DefaultMS
}
- }
+ }
#Function to create transitive members
@@ -97,32 +97,32 @@ function Invoke-CheckGroups {
[hashtable]$ReverseAdjList,
[hashtable]$AllGroupsHT
)
-
+
if ($TransitiveParentCache.ContainsKey($GroupId)) {
return $TransitiveParentCache[$GroupId]
}
-
+
$Visited = @{}
$Stack = New-Object System.Collections.Stack
$ResultHT = @{}
-
+
$Stack.Push($GroupId)
while ($Stack.Count -gt 0) {
$Current = $Stack.Pop()
-
+
if (-not $Visited.ContainsKey($Current)) {
$Visited[$Current] = $true
-
+
if ($ReverseAdjList.ContainsKey($Current)) {
foreach ($parent in $ReverseAdjList[$Current]) {
$parentId = $parent.id
-
+
if ($AllGroupsHT.ContainsKey($parentId)) {
# Only add to result if not already added
if (-not $ResultHT.ContainsKey($parentId)) {
$ResultHT[$parentId] = $AllGroupsHT[$parentId]
}
-
+
# Continue walking up only if we haven’t seen this parent
if (-not $Visited.ContainsKey($parentId)) {
$Stack.Push($parentId)
@@ -132,7 +132,7 @@ function Invoke-CheckGroups {
}
}
}
-
+
# Cache the result
$TransitiveParentCache[$GroupId] = $ResultHT.Values
return $ResultHT.Values
@@ -141,39 +141,41 @@ function Invoke-CheckGroups {
$NestedGroupCache = @{}
function Expand-NestedGroups-Cached {
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'CallerPSCmdlet')]
param (
[Parameter(Mandatory = $true)]
[object]$StartGroup,
-
+
[Parameter(Mandatory = $true)]
[hashtable]$GroupLookup,
-
- [Parameter(Mandatory = $true)]
+
+ # CallerPSCmdlet reserved for future ShouldProcess support
+ [Parameter(Mandatory = $false)]
[System.Management.Automation.PSCmdlet]$CallerPSCmdlet
)
-
+
# Return cached if available
if ($NestedGroupCache.ContainsKey($StartGroup.Id)) {
return $NestedGroupCache[$StartGroup.Id]
}
-
+
$allNestedGroups = [System.Collections.Generic.List[object]]::new()
$toProcess = [System.Collections.Queue]::new()
$visited = [System.Collections.Generic.HashSet[string]]::new()
-
+
$null = $toProcess.Enqueue($StartGroup)
$null = $visited.Add($StartGroup.Id)
-
+
while ($toProcess.Count -gt 0) {
$current = $toProcess.Dequeue()
-
+
$nestedGroups = $current.NestedGroupsDetails
if ($null -eq $nestedGroups -or $nestedGroups.Count -eq 0) { continue }
-
+
foreach ($nested in $nestedGroups) {
$nestedId = $nested.Id
if (-not $nestedId) { continue }
-
+
if ($visited.Add($nestedId)) {
$resolvedGroup = $GroupLookup[$nestedId]
if ($resolvedGroup) {
@@ -183,7 +185,7 @@ function Invoke-CheckGroups {
}
}
}
-
+
$NestedGroupCache[$StartGroup.Id] = $allNestedGroups
return $allNestedGroups
}
@@ -211,7 +213,7 @@ function Invoke-CheckGroups {
$GroupImpactScore = @{
"M365Group" = 1
- "HiddenGAL" = 1
+ "HiddenGAL" = 1
"Distribution" = 0.5
"SecurityEnabled" = 2
"AzureRole" = 100
@@ -233,7 +235,7 @@ function Invoke-CheckGroups {
"GuestMemberOwner" = 5
}
-
+
if ($TenantPimForGroupsAssignments) {
Write-Log -Level Verbose -Message "Processing $($TenantPimForGroupsAssignments.Count) PIM for Groups Assignments"
# Hashtable for all owners for faster lookup in each group
@@ -245,7 +247,7 @@ function Invoke-CheckGroups {
if (-not $PimForGroupsEligibleOwnersHT.ContainsKey($assignment.groupId)) {
$PimForGroupsEligibleOwnersHT[$assignment.groupId] = @() # Initialize as an empty array
}
-
+
#Add Properties depending on the object type
if ($assignment.Type -eq "User") {
$OwnerInfo = [PSCustomObject]@{
@@ -268,7 +270,7 @@ function Invoke-CheckGroups {
DisplayName = $GLOBALPimForGroupsHT[$assignment.groupId]
AssignmentType = "Eligible"
}
-
+
# Store the object in the Parent Group hashtable by principalId used to lookup in which group a group has ownership of
if (-not $PimForGroupsEligibleOwnerParentGroupHT.ContainsKey($assignment.principalId)) {
$PimForGroupsEligibleOwnerParentGroupHT[$assignment.principalId] = @() # Initialize as an empty array
@@ -283,7 +285,7 @@ function Invoke-CheckGroups {
AssignmentType = "Eligible"
}
}
-
+
# Add the object to the array for that groupId
$PimForGroupsEligibleOwnersHT[$assignment.groupId] += $OwnerInfo
}
@@ -298,7 +300,7 @@ function Invoke-CheckGroups {
if (-not $PimForGroupsEligibleMembersHT.ContainsKey($assignment.groupId)) {
$PimForGroupsEligibleMembersHT[$assignment.groupId] = @() # Initialize as an empty array
}
-
+
#Add Properties depending on the object type
if ($assignment.Type -eq "User") {
$MemberInfo = [PSCustomObject]@{
@@ -314,7 +316,7 @@ function Invoke-CheckGroups {
type = $assignment.Type
AssignmentType = "Eligible"
}
-
+
#Match "parent" infos. Needed to link from eligible to parent groups
$ParentInfo = [PSCustomObject]@{
Id = $assignment.groupId
@@ -337,10 +339,10 @@ function Invoke-CheckGroups {
AssignmentType = "Eligible"
}
}
-
+
# Add the object to the array for that groupId
$PimForGroupsEligibleMembersHT[$assignment.groupId] += $MemberInfo
-
+
}
}
@@ -378,7 +380,7 @@ function Invoke-CheckGroups {
$PmDataCollection = [System.Diagnostics.Stopwatch]::StartNew()
Write-Host "[*] Get Groups"
- $QueryParameters = @{
+ $QueryParameters = @{
'$select' = 'Id,DisplayName,Visibility,GroupTypes,SecurityEnabled,IsAssignableToRole,OnPremisesSyncEnabled,MailEnabled,Description,MembershipRule'
'$top' = $ApiTop
}
@@ -392,7 +394,7 @@ function Invoke-CheckGroups {
$AllGroupsDetailsHT = @{}
Return $AllGroupsDetailsHT
}
-
+
#Build Hashtable with basic group info. Needed in nesting scenarios to git information about parent / child group
@@ -434,10 +436,10 @@ function Invoke-CheckGroups {
$GroupMembers = @{}
$BatchSize = 10000
$ChunkCount = [math]::Ceiling($GroupsTotalCount / $BatchSize)
-
+
for ($chunkIndex = 0; $chunkIndex -lt $ChunkCount; $chunkIndex++) {
- Write-Log -Level Verbose -Message "Processing batch $($chunkIndex + 1) of $ChunkCount..."
-
+ Write-Log -Level Verbose -Message "Processing batch $($chunkIndex + 1) of $ChunkCount..."
+
$StartIndex = $chunkIndex * $BatchSize
$EndIndex = [math]::Min($StartIndex + $BatchSize - 1, $GroupsTotalCount - 1)
$GroupBatch = $AllGroups[$StartIndex..$EndIndex]
@@ -450,7 +452,7 @@ function Invoke-CheckGroups {
}
$Requests.Add($req)
}
-
+
# Send the batch
$Response = Send-GraphBatchRequest -AccessToken $GLOBALmsGraphAccessToken.access_token -Requests $Requests -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name) -QueryParameters @{'$select' = 'id,userType,onPremisesSyncEnabled' ;'$top'= $ApiTop}
@@ -463,6 +465,7 @@ function Invoke-CheckGroups {
}
+ $TotalGroupMembers = 0
foreach ($group in $GroupMembers.Values) {
$TotalGroupMembers += $group.Count
}
@@ -541,9 +544,9 @@ function Invoke-CheckGroups {
#Check token validity to ensure it will not expire in the next 30 minutes
if (-not (Invoke-CheckTokenExpiration $GLOBALmsGraphAccessToken)) { RefreshAuthenticationMsGraph | Out-Null}
-
+
Write-Host "[*] Calculate all group-to-parent-group relationships"
-
+
# Build reverse group membership map: child -> parent
$ReverseGroupMembershipMap = @{}
foreach ($parentGroupId in $GroupMembers.Keys) {
@@ -560,7 +563,7 @@ function Invoke-CheckGroups {
}
}
}
- }
+ }
$GroupNestedInRaw = @{}
foreach ($group in $AllGroups) {
@@ -583,7 +586,7 @@ function Invoke-CheckGroups {
foreach ($app in $RawResponse) {
$AllSPBasicHT[$app.id] = $app
}
-
+
#Remove Variables
remove-variable parents -ErrorAction SilentlyContinue
@@ -591,7 +594,7 @@ function Invoke-CheckGroups {
remove-variable Requests -ErrorAction SilentlyContinue
remove-variable AllUsersBasic -ErrorAction SilentlyContinue
remove-variable GroupMembers -ErrorAction SilentlyContinue
-
+
$PmDataCollection.Stop()
########################################## SECTION: Group Processing ##########################################
$PmDataProcessing = [System.Diagnostics.Stopwatch]::StartNew()
@@ -602,7 +605,7 @@ function Invoke-CheckGroups {
#region Processing Loop
# Loop through each group and get additional info
- foreach ($group in $AllGroups) {
+ foreach ($group in $AllGroups) {
#Loop init section
$ProgressCounter++
@@ -642,7 +645,7 @@ function Invoke-CheckGroups {
}
}
-
+
#Check if group has an app role
if ($AppRoleAssignmentsRaw.ContainsKey($group.Id)) {
foreach ($AppRole in $AppRoleAssignmentsRaw[$group.Id]) {
@@ -653,7 +656,7 @@ function Invoke-CheckGroups {
})
}
}
-
+
# Initialize ArrayLists
$memberUser = [System.Collections.Generic.List[psobject]]::new()
$memberGroup = [System.Collections.Generic.List[psobject]]::new()
@@ -666,7 +669,7 @@ function Invoke-CheckGroups {
if ($TransitiveMembersRaw.ContainsKey($group.Id)) {
foreach ($member in $TransitiveMembersRaw[$group.Id]) {
switch ($member.'@odata.type') {
-
+
'#microsoft.graph.user' {
[void]$memberUser.Add(
[PSCustomObject]@{
@@ -677,7 +680,7 @@ function Invoke-CheckGroups {
}
)
}
-
+
'#microsoft.graph.group' {
[void]$memberGroup.Add(
@@ -687,7 +690,7 @@ function Invoke-CheckGroups {
}
)
}
-
+
'#microsoft.graph.servicePrincipal' {
[void]$memberSP.Add(
[PSCustomObject]@{
@@ -695,7 +698,7 @@ function Invoke-CheckGroups {
}
)
}
-
+
'#microsoft.graph.device' {
[void]$memberDevices.Add(
[PSCustomObject]@{
@@ -711,7 +714,7 @@ function Invoke-CheckGroups {
if ($GroupOwnersRaw.ContainsKey($group.Id)) {
foreach ($Owner in $GroupOwnersRaw[$group.Id]) {
switch ($Owner.'@odata.type') {
-
+
'#microsoft.graph.user' {
[void]$owneruser.Add(
[PSCustomObject]@{
@@ -722,7 +725,7 @@ function Invoke-CheckGroups {
}
)
}
-
+
'#microsoft.graph.servicePrincipal' {
[void]$ownersp.Add(
[PSCustomObject]@{
@@ -730,7 +733,7 @@ function Invoke-CheckGroups {
}
)
}
-
+
default {
# Optional: log or handle unexpected owner types
Write-host "Unknown owner type: $($Owner.'@odata.type') for group $($group.Id)"
@@ -747,7 +750,7 @@ function Invoke-CheckGroups {
# Retrieve all owners for this group
$PfGownersGroup = @($PimForGroupsEligibleOwnersHT[$group.Id] | Where-Object { $_.type -eq "group" })
$PfGownersUser = @($PimForGroupsEligibleOwnersHT[$group.Id] | Where-Object { $_.type -eq "user" })
-
+
# Merge with normal owner list
foreach ($user in $PfGownersUser) {
[void]$owneruser.Add($user)
@@ -760,7 +763,7 @@ function Invoke-CheckGroups {
$PfGOwnedGroupsRaw = @($PimForGroupsEligibleOwnerParentGroupHT[$group.Id])
$PfGOwnedGroups = foreach ($OwnedGroup in $PfGOwnedGroupsRaw) {
#Get additonal proprties for the group
- if ($AllGroupsHT.ContainsKey($group.Id)) {
+ if ($AllGroupsHT.ContainsKey($OwnedGroup.Id)) {
$info = $AllGroupsHT[$OwnedGroup.Id]
[PSCustomObject]@{
Id = $OwnedGroup.Id
@@ -776,7 +779,7 @@ function Invoke-CheckGroups {
}
}
-
+
# Check if the group exists in the hashtable
if ($PimForGroupsEligibleMembersHT.ContainsKey($group.Id)) {
# Retrieve all members of the groups for this group
@@ -791,7 +794,7 @@ function Invoke-CheckGroups {
$PfGnestedGroupsRaw = @($PimForGroupsEligibleMemberParentGroupHT[$group.Id])
$PfGnestedGroups = foreach ($ParentGroup in $PfGnestedGroupsRaw) {
#Get additonal proprties for the group
- if ($AllGroupsHT.ContainsKey($group.Id)) {
+ if ($AllGroupsHT.ContainsKey($ParentGroup.Id)) {
$info = $AllGroupsHT[$ParentGroup.Id]
[PSCustomObject]@{
Id = $ParentGroup.Id
@@ -802,12 +805,12 @@ function Invoke-CheckGroups {
}
}
}
-
+
# Merge with normal nested list
foreach ($item in $PfGnestedGroups) {
$GroupNestedIn.Add($item)
}
- }
+ }
}
# If PIM for Group has been evaluated: Check if group is onboarded in PIM for Groups
@@ -818,7 +821,7 @@ function Invoke-CheckGroups {
} else {
$false
}
-
+
#Count the owners to show in table
$ownersynced = 0
@@ -875,7 +878,7 @@ function Invoke-CheckGroups {
# For all security enabled groups check if there are Azure IAM assignments
if ($GLOBALAzurePsChecks) {
if ($group.SecurityEnabled -eq $true) {
-
+
#Use function to get the Azure Roles for each object
$azureRoleDetails = Get-AzureRoleDetails -AzureIAMAssignments $AzureIAMAssignments -ObjectId $group.Id
@@ -895,7 +898,7 @@ function Invoke-CheckGroups {
# Find matching roles in $TenantRoleAssignments where the PrincipalId matches the group's Id
$MatchingRoles = $TenantRoleAssignments[$group.Id]
-
+
# Array to hold the role information for this group
$roleDetails = [System.Collections.Generic.List[object]]::new()
@@ -1049,7 +1052,7 @@ function Invoke-CheckGroups {
}
if ($RoleCount -ge 1) {
- #Add group to list for re-processing
+ #Add group to list for re-processing
if ($memberGroup.count -ge 1) {
$NestedGroupsHighvalue.Add([pscustomobject]@{
"Group" = $group.DisplayName
@@ -1119,7 +1122,7 @@ function Invoke-CheckGroups {
"TargetGroups" = $ownerGroup.Id
})
}
-
+
}
#Check if M365 group is public
@@ -1132,9 +1135,9 @@ function Invoke-CheckGroups {
$LikelihoodScore += $GroupLikelihoodScore["PublicM365Group"]
- if ($AppRoleAssignments.count -ge 1) {
+ if ($AppRoleAssignments.count -ge 1) {
[void]$Warnings.Add("Used for AppRoles")
- }
+ }
}
#Check for guests as owner
@@ -1145,7 +1148,7 @@ function Invoke-CheckGroups {
#Check if group is dynamic
if ($group.Dynamic -eq $true) {
- if ($AppRoleAssignments.count -ge 1) {
+ if ($AppRoleAssignments.count -ge 1) {
$ForAppRoles = " used for AppRoles"
} else {
$ForAppRoles = ""
@@ -1202,8 +1205,8 @@ function Invoke-CheckGroups {
if ($group.SecurityEnabled) {
$ImpactScore += $GroupImpactScore["SecurityEnabled"]
}
-
-
+
+
#Format warning messages
$Warnings = ($Warnings -join ' / ')
@@ -1236,8 +1239,8 @@ function Invoke-CheckGroups {
$ImpactOrgActiveOnly = [math]::Round([math]::Max(0, $ImpactScore - $EligibleRoleImpactContribution))
# Create custom object
- $groupDetails = [PSCustomObject]@{
- Id = $group.Id
+ $groupDetails = [PSCustomObject]@{
+ Id = $group.Id
DisplayName = $group.DisplayName
DisplayNameLink = "$($group.DisplayName)"
Type = $groupType
@@ -1347,18 +1350,18 @@ function Invoke-CheckGroups {
foreach ($highValueGroup in $NestedGroupsHighvalue) {
$targetIds = $highValueGroup.TargetGroups -split ','
-
+
foreach ($targetIdRaw in $targetIds) {
$targetId = $targetIdRaw.Trim()
-
+
# Skip self-nesting
if ($highValueGroup.GroupID -eq $targetId) { continue }
-
+
# Deduplicate highValueGroup → targetId
$pairKey = "$($highValueGroup.GroupID)|$targetId"
if ($processedGroupHighValuePairs.Contains($pairKey)) { continue }
$null = $processedGroupHighValuePairs.Add($pairKey)
-
+
$group = $GroupLookup[$targetId]
if (-not $group) { continue }
@@ -1367,16 +1370,16 @@ function Invoke-CheckGroups {
$group.EntraMaxTier = Merge-HigherTierLabel -CurrentTier $group.EntraMaxTier -CandidateTier $sourceGroup.EntraMaxTier
$group.AzureMaxTier = Merge-HigherTierLabel -CurrentTier $group.AzureMaxTier -CandidateTier $sourceGroup.AzureMaxTier
}
-
+
# Adjust impact + risk
$group.Impact += [math]::Round($highValueGroup.Score, 1)
$group.Risk = [math]::Ceiling($group.Impact * $group.Likelihood)
-
+
# Add role/CAP counts
if ($highValueGroup.CAPs) { $group.CAPs += $highValueGroup.CAPs }
if ($highValueGroup.EntraRoles) { $group.EntraRoles += $highValueGroup.EntraRoles }
if ($highValueGroup.AzureRoles) { $group.AzureRoles += $highValueGroup.AzureRoles }
-
+
# Update owned group (fast lookup through)
if ($group.PfGOwnedGroupsById.ContainsKey($highValueGroup.GroupID)) {
$ownedGroup = $group.PfGOwnedGroupsById[$highValueGroup.GroupID]
@@ -1384,7 +1387,7 @@ function Invoke-CheckGroups {
if ($highValueGroup.EntraRoles) { $ownedGroup.EntraRoles += $highValueGroup.EntraRoles }
if ($highValueGroup.AzureRoles) { $ownedGroup.AzureRoles += $highValueGroup.AzureRoles }
}
-
+
# Update parent group (fast lookup through HT)
if ($group.NestedInGroupsById.ContainsKey($highValueGroup.GroupID)) {
$parentGroup = $group.NestedInGroupsById[$highValueGroup.GroupID]
@@ -1392,7 +1395,7 @@ function Invoke-CheckGroups {
if ($highValueGroup.EntraRoles) { $parentGroup.EntraRoles += $highValueGroup.EntraRoles }
if ($highValueGroup.AzureRoles) { $parentGroup.AzureRoles += $highValueGroup.AzureRoles }
}
-
+
# Append warning
$message = $highValueGroup.Message
if ([string]::IsNullOrWhiteSpace($group.Warnings)) {
@@ -1400,7 +1403,7 @@ function Invoke-CheckGroups {
} elseif ($group.Warnings -notmatch [regex]::Escape($message)) {
$group.Warnings += " / $message"
}
-
+
$group.InheritedHighValue += 1
}
}
@@ -1413,7 +1416,7 @@ function Invoke-CheckGroups {
$StatusUpdateInterval = [Math]::Max([Math]::Floor($GroupsWithNestingsCount / 10), 1)
Write-Log -Level Debug -Message "Status: Processing group 1 of $GroupsWithNestingsCount (updates every $StatusUpdateInterval groups)..."
$ProgressCounter = 0
-
+
#Reprocessing groups which have a nested group to include their owners -> Parent group is adjusted
# Pre-create hash sets for faster containment checks
@@ -1432,7 +1435,7 @@ function Invoke-CheckGroups {
# Step 2: Aggregate owners from nested groups
-
+
foreach ($match in $allNestedGroups) {
$matchingGroup = $GroupLookup[$match.Id]
if (-not $matchingGroup) { continue }
@@ -1489,7 +1492,7 @@ function Invoke-CheckGroups {
#Define output of the main table
$tableOutput = $AllGroupsDetails | Sort-Object Risk -Descending | select-object DisplayName,DisplayNameLink,Type,SecurityEnabled,RoleAssignable,OnPrem,Dynamic,Visibility,Protected,PIM,AuUnits,DirectOwners,NestedOwners,OwnersSynced,Users,Guests,SPCount,Devices,NestedGroups,NestedInGroups,AppRoles,CAPs,EntraRoles,EntraMaxTier,AzureRoles,AzureMaxTier,Impact,Likelihood,Risk,Warnings
-
+
# Apply result limit for the main table
if ($LimitResults -and $LimitResults -gt 0) {
$tableOutput = $tableOutput | Select-Object -First $LimitResults
@@ -1524,7 +1527,7 @@ function Invoke-CheckGroups {
#Define the apps to be displayed in detail and sort them by risk score
$details = $AllGroupsDetails | Sort-Object Risk -Descending
-
+
# Apply limit for details
if ($LimitResults -and $LimitResults -gt 0) {
$details = $details | Select-Object -First $LimitResults
@@ -1598,7 +1601,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
if ($item.Dynamic) {
$ReportingGroupInfo | Add-Member -NotePropertyName DynamicRule -NotePropertyValue $item.MembershipRule
}
-
+
if ($item.Warnings -ne '') {
$ReportingGroupInfo | Add-Member -NotePropertyName Warnings -NotePropertyValue $item.Warnings
}
@@ -1618,16 +1621,16 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
[void]$DetailTxtBuilder.AppendLine("$name : $value")
}
[void]$DetailTxtBuilder.AppendLine("")
-
+
############### Administrative Units
if (@($item.AuUnitsDetails).Count -ge 1) {
foreach ($object in $item.AuUnitsDetails) {
- [void]$ReportingAU.Add([pscustomobject]@{
+ [void]$ReportingAU.Add([pscustomobject]@{
"Administrative Unit" = $object.Displayname
"IsMemberManagementRestricted" = $object.IsMemberManagementRestricted
})
}
-
+
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine("Administrative Units")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
@@ -1637,7 +1640,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
############### Entra Roles
if (@($item.EntraRoleDetails).count -ge 1) {
foreach ($object in $item.EntraRoleDetails) {
- [void]$ReportingRoles.Add([pscustomobject]@{
+ [void]$ReportingRoles.Add([pscustomobject]@{
"Role name" = $object.DisplayName
"Assignment" = $object.AssignmentType
"Tier Level" = $object.RoleTier
@@ -1646,7 +1649,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
"Scoped to" = "$($object.ScopeResolved.DisplayName) ($($object.ScopeResolved.Type))"
})
}
-
+
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine("Entra Role Assignments")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
@@ -1656,7 +1659,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
############### Azure Roles
if (@($item.AzureRoleDetails).Count -ge 1) {
foreach ($role in $item.AzureRoleDetails) {
- [void]$ReportingAzureRoles.Add([pscustomobject]@{
+ [void]$ReportingAzureRoles.Add([pscustomobject]@{
"Role name" = $role.RoleName
"Assignment" = $role.AssignmentType
"RoleType" = $role.RoleType
@@ -1665,18 +1668,18 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
"Scoped to" = $role.Scope
})
}
-
+
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine("Azure IAM assignments")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine(($ReportingAzureRoles | format-table | Out-String))
- }
+ }
############### CAPs
if ($item.GroupCAPsDetails.Count -ge 1) {
$ReportingCAPsRaw = [System.Collections.Generic.List[object]]::new()
-
+
$CapNameLength = 0
foreach ($object in $item.GroupCAPsDetails) {
# Calc Max Length
@@ -1690,21 +1693,21 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
Usage = $object.CAPExOrIn
Status = $object.CAPStatus
}
-
+
[void]$ReportingCAPsRaw.Add($txtObj)
-
+
[void]$ReportingCAPs.Add([pscustomobject]@{
CAPName = "$($CapName)"
Usage = $object.CAPExOrIn
Status = $object.CAPStatus
})
}
-
+
$formattedText = Format-ReportSection -Title "Linked Conditional Access Policies" `
-Objects $ReportingCAPsRaw `
-Properties @("CAPName", "Usage", "Status") `
-ColumnWidths @{ CAPName = [Math]::Min($CapNameLength, 120); Usage = 9; Status = 8}
-
+
[void]$DetailTxtBuilder.AppendLine($formattedText)
}
@@ -1721,22 +1724,22 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$ResourceDisplayNameLength = $ResourceDisplayName.Length
}
- $appObj = [pscustomobject]@{
+ $appObj = [pscustomobject]@{
UsedIn = $ResourceDisplayName
UsedInLink = "$($ResourceDisplayName)"
AppRoleId = $object.AppRoleId
}
[void]$AppRolesRaw.Add($appObj)
}
-
+
# Output for TXT report
$formattedText = Format-ReportSection -Title "App Roles" `
-Objects $AppRolesRaw `
-Properties @("UsedIn", "AppRoleId") `
-ColumnWidths @{ UsedIn = [Math]::Min($ResourceDisplayNameLength, 50); AppRoleId = 40 }
-
+
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
+
# Rebuild for HTML report
foreach ($obj in $AppRolesRaw) {
[void]$AppRoles.Add([pscustomobject]@{
@@ -1752,7 +1755,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$OwnerUserRaw = [System.Collections.Generic.List[object]]::new()
$UsernameLength = 0
-
+
foreach ($object in $item.OwnerUserDetails) {
$userDetails = $AllUsersBasicHT[$object.id]
if (-not $userDetails.onPremisesSyncEnabled) { $userDetails.onPremisesSyncEnabled = "False" }
@@ -1764,7 +1767,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
}
# Add raw user data to the list
- $userObj = [pscustomobject]@{
+ $userObj = [pscustomobject]@{
"AssignmentType" = $object.AssignmentType
"Username" = $Username
"UsernameLink" = "$($Username)"
@@ -1781,7 +1784,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
-Objects $OwnerUserRaw `
-Properties @("AssignmentType", "Username", "Enabled", "Type", "Synced") `
-ColumnWidths @{ AssignmentType = 14; Username = [Math]::Min($UsernameLength, 60); Enabled = 7; Type = 7; Synced = 6}
-
+
[void]$DetailTxtBuilder.AppendLine($formattedText)
# Rebuild for HTML report
@@ -1821,7 +1824,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$azureMaxTier = if ($null -ne $groupDetails.AzureMaxTier) { $groupDetails.AzureMaxTier } else { if ($GLOBALAzurePsChecks) { "-" } else { "?" } }
$roleAssignable = if ($null -ne $groupDetails.RoleAssignable) { $groupDetails.RoleAssignable } else { $groupDetails.IsAssignableToRole }
- $groupObj = [pscustomobject]@{
+ $groupObj = [pscustomobject]@{
"AssignmentType" = $object.AssignmentType
"Displayname" = $GroupName
"DisplayNameLink" = "$($GroupName)"
@@ -1858,16 +1861,16 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$OwnerSPRaw = [System.Collections.Generic.List[object]]::new()
$DisplayNameLength = 0
-
+
foreach ($object in $item.ownerSpDetails) {
# Calc Max Length
$DisplayName = $object.displayName
if ($null -ne $DisplayName -and $DisplayName.Length -gt $DisplayNameLength) {
$DisplayNameLength = $DisplayName.Length
- }
+ }
- $ownerObj = [pscustomobject]@{
+ $ownerObj = [pscustomobject]@{
DisplayName = $DisplayName
DisplayNameLink = "$($DisplayName)"
Type = $object.SPType
@@ -1877,14 +1880,14 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
}
[void]$OwnerSPRaw.Add($ownerObj)
}
-
+
# Build TXT
$formattedText = Format-ReportSection -Title "Direct Owners (Service Principals" `
-Objects $OwnerSPRaw `
-Properties @("DisplayName", "Type", "Org", "Foreign", "DefaultMS") `
-ColumnWidths @{ DisplayName = [Math]::Min($DisplayNameLength, 45); Type = 20; Org = 45; Foreign = 8; DefaultMS = 10 }
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
+
# Rebuild for HTML report
foreach ($obj in $OwnerSPRaw) {
[void]$OwnerSP.Add([pscustomobject]@{
@@ -1913,7 +1916,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$UsernameLength = $Username.Length
}
- $userObj = [pscustomobject]@{
+ $userObj = [pscustomobject]@{
"AssignmentType" = $object.AssignmentType
"Username" = $Username
"UsernameLink" = "$($Username)"
@@ -1939,7 +1942,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
-Objects $NestedOwnerUser `
-Properties @("AssignmentType", "Username", "Enabled", "Type", "Synced") `
-ColumnWidths @{ AssignmentType = 14; Username = [Math]::Min($UsernameLength, 60); Enabled = 7; Type = 7; Synced = 6}
-
+
[void]$DetailTxtBuilder.AppendLine($formattedText)
#Rebuild for HTML report
@@ -1969,13 +1972,13 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
############### Nested Groups
if (@($item.NestedGroupsDetails).Count -ge 1) {
$NestedGroupsRaw = [System.Collections.Generic.List[object]]::new()
-
+
foreach ($object in $item.NestedGroupsDetails) {
$groupDetails = $GroupLookup[$object.id]
if (-not $groupDetails) { $groupDetails = $AllGroupsHT[$object.id] }
$groupName = if ($null -ne $groupDetails.DisplayName) { $groupDetails.DisplayName } else { $groupDetails.displayName }
$roleAssignable = if ($null -ne $groupDetails.RoleAssignable) { $groupDetails.RoleAssignable } else { $groupDetails.IsAssignableToRole }
-
+
$rawObj = [pscustomobject]@{
AssignmentType = $object.AssignmentType
DisplayName = $groupName
@@ -1983,10 +1986,10 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
SecurityEnabled = $groupDetails.SecurityEnabled
IsAssignableToRole = $roleAssignable
}
-
+
[void]$NestedGroupsRaw.Add($rawObj)
}
-
+
# Sort by role assignability & security for both TXT and HTML
$SortedNestedGroups = $NestedGroupsRaw | Sort-Object {
$priority = 0
@@ -1994,18 +1997,18 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
if (-not $_.SecurityEnabled) { $priority += 1 }
return $priority
}
-
+
# Build TXT
$formattedText = Format-ReportSection -Title "Nested Members: Nested Groups" `
-Objects $SortedNestedGroups `
-Properties @("AssignmentType", "Displayname", "SecurityEnabled", "IsAssignableToRole") `
-ColumnWidths @{ AssignmentType = 15; Displayname = 60; SecurityEnabled = 16; IsAssignableToRole = 19 }
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
+
# Limit for HTML
$ExceedsLimit = $SortedNestedGroups.Count -gt $HTMLNestedGroupsLimit
$GroupsToShow = if ($ExceedsLimit) { $SortedNestedGroups[0..($HTMLNestedGroupsLimit - 1)] } else { $SortedNestedGroups }
-
+
foreach ($obj in $GroupsToShow) {
[void]$NestedGroups.Add([pscustomobject]@{
AssignmentType = $obj.AssignmentType
@@ -2014,7 +2017,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
IsAssignableToRole = $obj.IsAssignableToRole
})
}
-
+
if ($ExceedsLimit) {
[void]$NestedGroups.Add([pscustomobject]@{
AssignmentType = "-"
@@ -2024,8 +2027,8 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
})
}
}
-
-
+
+
############### Nested Users
@@ -2035,10 +2038,10 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
#Set lenght to 0
$UsernameLength = 0
-
+
foreach ($object in $item.UserDetails) {
$userDetails = $AllUsersBasicHT[$object.id]
-
+
if (-not $userDetails.onPremisesSyncEnabled) { $userDetails.onPremisesSyncEnabled = "False" }
# Calc Max Length
@@ -2048,25 +2051,25 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
}
$linkedUsername = "$($Username)"
-
+
# Plain for TXT
- $txtObj = [pscustomobject]@{
+ $txtObj = [pscustomobject]@{
AssignmentType = $object.AssignmentType
Username = $Username
Enabled = $userDetails.accountEnabled
Type = $userDetails.userType
Synced = $userDetails.onPremisesSyncEnabled
}
-
+
# Linked for HTML
- $htmlObj = [pscustomobject]@{
+ $htmlObj = [pscustomobject]@{
AssignmentType = $object.AssignmentType
Username = $linkedUsername
Enabled = $userDetails.accountEnabled
Type = $userDetails.userType
Synced = $userDetails.onPremisesSyncEnabled
}
-
+
if ($ObjectCounter -lt $HTMLMemberLimit) {
[void]$NestedUsers.Add($htmlObj)
} elseif ($ObjectCounter -eq $HTMLMemberLimit) {
@@ -2078,18 +2081,18 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
Synced = "-"
})
}
-
+
[void]$NestedUsersTXT.Add($txtObj)
$ObjectCounter++
}
-
+
$formattedText = Format-ReportSection -Title "Nested Members: Users" `
-Objects $NestedUsersTXT `
-Properties @("AssignmentType", "Username", "Enabled", "Type", "Synced") `
-ColumnWidths @{ AssignmentType = 14; Username = [Math]::Min($UsernameLength, 60); Enabled = 7; Type = 7; Synced = 6}
-
+
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
+
}
############### Nested SP
@@ -2097,7 +2100,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$NestedSPRaw = [System.Collections.Generic.List[object]]::new()
$DisplayNameLength = 0
-
+
foreach ($object in $item.MemberSpDetails) {
# Calc Max Length
@@ -2113,7 +2116,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$DisplayNameLink = "$($DisplayName)"
$org = "-"
}
-
+
$rawObj = [pscustomobject]@{
DisplayName = $DisplayName
DisplayNameLink = $DisplayNameLink
@@ -2122,10 +2125,10 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
Foreign = $object.Foreign
DefaultMS = $object.DefaultMS
}
-
+
[void]$NestedSPRaw.Add($rawObj)
}
-
+
# Build TXT
$formattedText = Format-ReportSection -Title "Nested Members: Service Principals" `
-Objects $NestedSPRaw `
@@ -2133,7 +2136,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
-ColumnWidths @{ DisplayName = [Math]::Min($DisplayNameLength, 55); Type = 20; Org = 45; Foreign = 8; DefaultMS = 10 }
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
+
foreach ($obj in $NestedSPRaw) {
[void]$NestedSP.Add([pscustomobject]@{
DisplayName = $obj.DisplayNameLink
@@ -2164,23 +2167,23 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
if ($null -ne $Os -and $Os.Length -gt $OsLength) {
$OsLength = $Os.Length
}
-
+
$rawObj = [pscustomobject]@{
Displayname = $DiplayName
Type = $DeviceDetails.trustType
OS = $Os
}
-
+
[void]$NestedDevicesRaw.Add($rawObj)
}
-
+
# Build TXT
$formattedText = Format-ReportSection -Title "Nested Members: Devices" `
-Objects $NestedDevicesRaw `
-Properties @("Displayname", "Type", "OS") `
-ColumnWidths @{ Displayname = [Math]::Min($DiplayNameLength, 30); Enabled = 8; Type = 15; OS = [Math]::Min($OsLength, 40) }
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
+
# Limit HTML output
$ExceedsLimit = $NestedDevicesRaw.Count -gt $HTMLMemberLimit
if ($ExceedsLimit -and $HTMLMemberLimit -gt 0) {
@@ -2224,7 +2227,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$roleAssignable = if ($null -ne $groupDetails.RoleAssignable) { $groupDetails.RoleAssignable } else { $groupDetails.IsAssignableToRole }
$entraMaxTier = if ($null -ne $groupDetails.EntraMaxTier) { $groupDetails.EntraMaxTier } else { "-" }
$azureMaxTier = if ($null -ne $groupDetails.AzureMaxTier) { $groupDetails.AzureMaxTier } else { if ($GLOBALAzurePsChecks) { "-" } else { "?" } }
-
+
$rawObj = [pscustomobject]@{
AssignmentType = $object.AssignmentType
Displayname = $GroupName
@@ -2237,22 +2240,22 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
AzureMaxTier = $azureMaxTier
CAPs = $object.CAPs
}
-
+
[void]$NestedInGroupsRaw.Add($rawObj)
}
-
+
# Build TXT
$formattedText = Format-ReportSection -Title "Member Of: Nested in Groups (Transitive)" `
-Objects $NestedInGroupsRaw `
-Properties @("AssignmentType", "Displayname", "SecurityEnabled", "IsAssignableToRole", "EntraRoles", "EntraMaxTier", "AzureRoles", "AzureMaxTier", "CAPs") `
-ColumnWidths @{ AssignmentType = 15; Displayname = [Math]::Min($GroupNameLength, 60); SecurityEnabled = 16; IsAssignableToRole = 19; EntraRoles = 11; EntraMaxTier = 11; AzureRoles = 11; AzureMaxTier = 11; CAPs = 4 }
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
+
# Sort only for HTML
$SortedNestedGroups = $NestedInGroupsRaw | Sort-Object {
if ($_.EntraRoles -or $_.AzureRoles -or $_.CAPs) { 0 } else { 1 }
}
-
+
# Apply HTML limit
$ExceedsLimit = $SortedNestedGroups.Count -gt $HTMLNestedGroupsLimit
if ($ExceedsLimit -and $HTMLNestedGroupsLimit -gt 0) {
@@ -2260,7 +2263,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
} else {
$GroupsToShow = $SortedNestedGroups
}
-
+
foreach ($obj in $GroupsToShow) {
[void]$NestedInGroups.Add([pscustomobject]@{
AssignmentType = $obj.AssignmentType
@@ -2274,7 +2277,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
CAPs = $obj.CAPs
})
}
-
+
if ($ExceedsLimit) {
[void]$NestedInGroups.Add([pscustomobject]@{
AssignmentType = "-"
@@ -2307,7 +2310,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
$GroupNameLength = $GroupName.Length
}
- [void]$OwnedGroupsRaw.Add([pscustomobject]@{
+ [void]$OwnedGroupsRaw.Add([pscustomobject]@{
AssignmentType = $object.AssignmentType
Displayname = $GroupName
DisplayNameLink = "$($GroupName)"
@@ -2320,15 +2323,15 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
CAPs = $object.CAPs
})
}
-
+
$formattedText = Format-ReportSection -Title "Owned Groups (PIM for Groups)" `
-Objects $OwnedGroupsRaw `
-Properties @("AssignmentType", "Displayname", "SecurityEnabled", "IsAssignableToRole", "EntraRoles", "EntraMaxTier", "AzureRoles", "AzureMaxTier", "CAPs") `
-ColumnWidths @{ AssignmentType = 15; Displayname = [Math]::Min($GroupNameLength, 60); SecurityEnabled = 16; IsAssignableToRole = 19; EntraRoles = 11; EntraMaxTier = 11; AzureRoles = 11; AzureMaxTier = 11; CAPs = 4 }
-
+
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
-
+
+
# Rebuild for HTML report
foreach ($obj in $OwnedGroupsRaw) {
[void]$OwnedGroups.Add([pscustomobject]@{
@@ -2344,7 +2347,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
})
}
}
-
+
$ObjectDetails = [pscustomobject]@{
"Object Name" = $item.DisplayName
"Object ID" = $item.Id
@@ -2361,12 +2364,12 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr
"Nested owner (SP)" = $NestedOwnerSP
"Nested Groups" = $NestedGroups
"Nested Users" = $NestedUsers
- "Nested SP" = $NestedSP
- "Nested Devices " = $NestedDevices
+ "Nested SP" = $NestedSP
+ "Nested Devices " = $NestedDevices
"Nested in Groups " = $NestedInGroups
"Owned Groups (PIM for Groups)" = $OwnedGroups
}
-
+
[void]$AllObjectDetailsHTML.Add($ObjectDetails)
#Write TXT report chunk
@@ -2413,7 +2416,7 @@ $AppendixTitle = "
Appendix: Dynamic Groups
###############################################################################################################################################
"
-
+
$PmGeneratingDetails.Stop()
$PmWritingReports = [System.Diagnostics.Stopwatch]::StartNew()
@@ -2474,7 +2477,7 @@ $headerHtml = @"
}
if ($group.PIM -eq $true) {
$PimOnboarded++
- }
+ }
}
# Store in global var
@@ -2526,7 +2529,7 @@ $headerHtml = @"
NestedOwners = $group.NestedOwners
}
}
-
+
Remove-Variable Report
Remove-Variable tableOutput
Remove-Variable AllGroupsDetails
diff --git a/modules/check_ManagedIdentities.psm1 b/modules/check_ManagedIdentities.psm1
index 4de8a0f..78e2adf 100644
--- a/modules/check_ManagedIdentities.psm1
+++ b/modules/check_ManagedIdentities.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Enumerate Managed Identities (including: API Permission, Source Tenant, Groups, Roles).
#>
@@ -57,7 +57,7 @@ function Invoke-CheckManagedIdentities {
}
$ManagedIdentities = @(Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri '/servicePrincipals' -QueryParameters $QueryParameters -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name))
-
+
$ManagedIdentitiesCount = $($ManagedIdentities.count)
write-host "[+] Got $ManagedIdentitiesCount Managed Identities"
@@ -84,7 +84,7 @@ function Invoke-CheckManagedIdentities {
if ($null -ne $item.AppRoles) {
$role = $item.AppRoles | Where-Object {$_.AllowedMemberTypes -contains "Application"} | select-object id,DisplayName,Value,Description
foreach ($permission in $role) {
- [PSCustomObject]@{
+ [PSCustomObject]@{
AppID = $item.Id
AppName = $item.DisplayName
ApiPermissionId = $permission.id
@@ -92,7 +92,7 @@ function Invoke-CheckManagedIdentities {
ApiPermissionDisplayName = $permission.DisplayName
ApiPermissionDescription = $permission.Description
ApiPermissionCategorization = Get-APIPermissionCategory -InputPermission $permission.id -PermissionType "application"
- }
+ }
}
}
}
@@ -167,19 +167,19 @@ function Invoke-CheckManagedIdentities {
if ($ManagedIdentitiesCount -gt 0 -and $StatusUpdateInterval -gt 1) {
Write-Host "[*] Status: Processing managed identity 1 of $ManagedIdentitiesCount (updates every $StatusUpdateInterval managed identities)..."
}
-
+
#region Processing Loop
#Loop through each Managed Identity and get additional info and store it in a custom object
foreach ($item in $ManagedIdentities) {
$ProgressCounter++
$ImpactScore = $SPImpactScore["Base"]
- $LikelihoodScore = $SPLikelihoodScore["Base"]
+ $LikelihoodScore = $SPLikelihoodScore["Base"]
$warnings = @()
$WarningsHighPermission = $null
$WarningsDangerousPermission = $null
$AppCredentials = @()
$OwnerSPDetails = @()
-
+
# Display status based on the objects numbers (slightly improves performance)
if ($ProgressCounter % $StatusUpdateInterval -eq 0 -or $ProgressCounter -eq $ManagedIdentitiesCount) {
@@ -221,7 +221,7 @@ function Invoke-CheckManagedIdentities {
)
}
}
-
+
#Get the applications API permission
$AppApiPermission = [System.Collections.ArrayList]::new()
foreach ($AppSinglePermission in $AppAssignments) {
@@ -283,13 +283,13 @@ function Invoke-CheckManagedIdentities {
} else {
$AzureRoleCount = "?"
}
-
+
# Enumerate all roles including scope the app is assigned to (note: Get-MgBetaServicePrincipalMemberOf do not return custom roles or scoped roles)
$MatchingRoles = $TenantRoleAssignments[$item.Id]
$AppEntraRoles = @()
- $AppEntraRoles = foreach ($Role in $MatchingRoles) {
- [PSCustomObject]@{
+ $AppEntraRoles = foreach ($Role in $MatchingRoles) {
+ [PSCustomObject]@{
Type = "Roles"
DisplayName = $Role.DisplayName
Enabled = $Role.IsEnabled
@@ -380,7 +380,7 @@ function Invoke-CheckManagedIdentities {
}
#Process owned groups
- $OwnedGroups = foreach ($Group in $OwnedGroups) {
+ $OwnedGroups = foreach ($Group in $OwnedGroups) {
Get-GroupDetails -Group $Group -AllGroupsDetails $AllGroupsDetails
}
@@ -424,7 +424,7 @@ function Invoke-CheckManagedIdentities {
$OwnedApplicationsCount = $OwnedApplications.count
$OwnedSPCount = $OwnedSP.count
-
+
#Check if sp has configured credentials
$AppCredentialsSecrets = foreach ($creds in $item.PasswordCredentials) {
@@ -447,7 +447,7 @@ function Invoke-CheckManagedIdentities {
$AppCredentials += $AppCredentialsCertificates
- ########################################## SECTION: RISK RATING AND WARNINGS ##########################################
+ ########################################## SECTION: RISK RATING AND WARNINGS ##########################################
$AppCredentialsCount = ($AppCredentials | Measure-Object).count
if ($AzureRoleCount -ge 1) {
@@ -459,12 +459,12 @@ function Invoke-CheckManagedIdentities {
#If SP owns App Registration
if ($OwnedApplicationsCount -ge 1) {
- $Warnings += "SP owns $OwnedApplicationsCount App Registrations!"
+ $Warnings += "SP owns $OwnedApplicationsCount App Registrations!"
}
#If SP owns App Registration
if ($OwnedSPCount -ge 1) {
- $Warnings += "SP owns $OwnedSPCount Enterprise Applications!"
+ $Warnings += "SP owns $OwnedSPCount Enterprise Applications!"
}
#Process group memberships
@@ -512,7 +512,7 @@ function Invoke-CheckManagedIdentities {
} else {
$privileged = ""
}
-
+
$Warnings += "$($privileged)Entra role(s) through group membership"
}
@@ -641,7 +641,7 @@ function Invoke-CheckManagedIdentities {
if ($severities.Count -gt 0) {
$lastIndex = $severities.Count - 1
$last = $severities[$lastIndex]
-
+
if ($severities.Count -gt 1) {
$first = $severities[0..($lastIndex - 1)] -join ", "
$joined = "$first and $last"
@@ -661,7 +661,7 @@ function Invoke-CheckManagedIdentities {
''
}
#Write custom object
- $SPInfo = [PSCustomObject]@{
+ $SPInfo = [PSCustomObject]@{
Id = $item.Id
DisplayName = $item.DisplayName
DisplayNameLink = "$($item.DisplayName)"
@@ -713,7 +713,7 @@ function Invoke-CheckManagedIdentities {
#Define output of the main table
$tableOutput = $AllServicePrincipal | Sort-Object -Property risk -Descending | select-object DisplayName,DisplayNameLink,IsExplicit,CreationInDays,GroupMembership,GroupOwnership,AppOwnership,SpOwn,EntraRoles,EntraMaxTier,AppCredentials,AzureRoles,AzureMaxTier,ApiDangerous, ApiHigh, ApiMedium, ApiLow, ApiMisc,Impact,Likelihood,Risk,Warnings
-
+
#Define the apps to be displayed in detail and sort them by risk score
$details = $AllServicePrincipal | Sort-Object Risk -Descending
@@ -753,11 +753,11 @@ function Invoke-CheckManagedIdentities {
}
[void]$DetailTxtBuilder.AppendLine(($ReportingMIInfo | Select-Object $TxtReportProps | Out-String))
-
+
############### Entra Roles
if ($($item.EntraRoleDetails | Measure-Object).count -ge 1) {
$ReportingRoles = foreach ($object in $($item.EntraRoleDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role name" = $($object.DisplayName)
"Tier Level" = $($object.RoleTier)
"Privileged" = $($object.isPrivileged)
@@ -771,12 +771,12 @@ function Invoke-CheckManagedIdentities {
[void]$DetailTxtBuilder.AppendLine("================================================================================================`n")
[void]$DetailTxtBuilder.AppendLine(($ReportingRoles | Out-String))
}
-
+
############### Azure Roles
if ($($item.AzureRoleDetails | Measure-Object).count -ge 1) {
$ReportingAzureRoles = foreach ($object in $($item.AzureRoleDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role name" = $($object.RoleName)
"RoleType" = $($object.RoleType)
"Tier Level" = $($object.RoleTier)
@@ -795,7 +795,7 @@ function Invoke-CheckManagedIdentities {
############### Group Owner
if ($($item.GroupOwner | Measure-Object).count -ge 1) {
$ReportingGroupOwner = foreach ($object in $($item.GroupOwner)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
"SecurityEnabled" = $($object.SecurityEnabled)
@@ -812,7 +812,7 @@ function Invoke-CheckManagedIdentities {
[void]$DetailTxtBuilder.AppendLine("Owner of Groups`n")
[void]$DetailTxtBuilder.AppendLine("================================================================================================`n")
[void]$DetailTxtBuilder.AppendLine(($ReportingGroupOwner | Format-Table DisplayName,SecurityEnabled,RoleAssignable,EntraRoles,AzureRoles,CAPs,ImpactOrg,Warnings | Out-String))
-
+
$ReportingGroupOwner = foreach ($obj in $ReportingGroupOwner) {
[pscustomobject]@{
DisplayName = $obj.DisplayNameLink
@@ -825,12 +825,12 @@ function Invoke-CheckManagedIdentities {
Warnings = $obj.Warnings
}
}
- }
+ }
############### App owner
if ($($item.OwnedApplicationsDetails | Measure-Object).count -ge 1) {
$ReportingAppOwner = foreach ($object in $($item.OwnedApplicationsDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
}
@@ -852,7 +852,7 @@ function Invoke-CheckManagedIdentities {
############### Group Member
if ($($item.GroupMember | Measure-Object).count -ge 1) {
$ReportingGroupMember = foreach ($object in $($item.GroupMember)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $($object.DisplayName)
"DisplayNameLink" = "$($object.DisplayName)"
"SecurityEnabled" = $($object.SecurityEnabled)
@@ -881,12 +881,12 @@ function Invoke-CheckManagedIdentities {
Warnings = $obj.Warnings
}
}
- }
+ }
############### Managed Identity Credentials
if ($($item.AppCredentialsDetails | Measure-Object).count -ge 1) {
$ReportingCredentials = foreach ($object in $($item.AppCredentialsDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Type" = $($object.Type)
"DisplayName" = $($object.DisplayName)
"StartDateTime" = $(if ($null -ne $object.StartDateTime) { $object.StartDateTime.ToString() } else { "-" })
@@ -898,12 +898,12 @@ function Invoke-CheckManagedIdentities {
[void]$DetailTxtBuilder.AppendLine("Managed Identity Credentials`n")
[void]$DetailTxtBuilder.AppendLine("================================================================================================`n")
[void]$DetailTxtBuilder.AppendLine(($ReportingCredentials | Out-String -Width 512))
- }
+ }
############### API permission
if ($($item.AppApiPermission | Measure-Object).count -ge 1) {
$ReportingAPIPermission = foreach ($object in $($item.AppApiPermission)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"API" = $($object.ApiName)
"Category" = $($object.ApiPermissionCategorization)
"Permission" = $($object.ApiPermission)
@@ -930,7 +930,7 @@ function Invoke-CheckManagedIdentities {
"Owner of Groups" = $ReportingGroupOwner
"Credentials" = $ReportingCredentials
}
-
+
[void]$AllObjectDetailsHTML.Add($ObjectDetails)
@@ -1024,7 +1024,7 @@ $headerHtml = @"
$Report = ConvertTo-HTML -Body "$headerHTML $mainTableHTML" -Title "$Title Enumeration" -Head ($global:GLOBALReportManifestScript + $global:GLOBALCss) -PostContent $PostContentCombined -PreContent $AllObjectDetailsHTML
$Report | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).html"
$OutputFormats = if ($Csv) { "CSV,TXT,HTML" } else { "TXT,HTML" }
- write-host "[+] Details of $ManagedIdentitiesCount Managed Identities stored in output files ($OutputFormats): $outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
+ write-host "[+] Details of $ManagedIdentitiesCount Managed Identities stored in output files ($OutputFormats): $outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
} else {
write-host "[-] No managed Identities exist."
write-host "[-] No logs have been written."
@@ -1052,7 +1052,7 @@ $headerHtml = @"
# Store in global var
$GlobalAuditSummary.ManagedIdentities.Count = $ManagedIdentitiesCount
- $GlobalAuditSummary.ManagedIdentities.IsExplicit = $IsExplicit
+ $GlobalAuditSummary.ManagedIdentities.IsExplicit = $IsExplicit
$GlobalAuditSummary.ManagedIdentities.ApiCategorization.Dangerous = $AppApiDangerous
$GlobalAuditSummary.ManagedIdentities.ApiCategorization.High = $AppApiHigh
$GlobalAuditSummary.ManagedIdentities.ApiCategorization.Medium = $AppApiMedium
diff --git a/modules/check_PIM.psm1 b/modules/check_PIM.psm1
index 887d6bd..2eca987 100644
--- a/modules/check_PIM.psm1
+++ b/modules/check_PIM.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Enumerates PIM role configuration.
#>
@@ -6,6 +6,8 @@
function Invoke-CheckPIM {
############################## Parameter section ########################
+ # Users is passed by the shared calling interface but not consumed by this module
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Users')]
[CmdletBinding()]
Param (
[Parameter(Mandatory=$false)][string]$OutputFolder = ".",
@@ -21,7 +23,7 @@ function Invoke-CheckPIM {
############################## Function section ########################
#Function to parse ISO8601 used in PIM
- function Parse-ISO8601Duration {
+ function ConvertFrom-ISO8601Duration {
param (
[string]$DurationString,
@@ -77,7 +79,7 @@ function Invoke-CheckPIM {
########################################## SECTION: DATACOLLECTION ##########################################
-
+
# Check if access token for PIM is still valid. Refresh if required
if (-not (Invoke-CheckTokenExpiration $GLOBALPIMsGraphAccessToken)) {Invoke-MsGraphRefreshPIM | Out-Null}
@@ -87,18 +89,18 @@ function Invoke-CheckPIM {
write-host "[*] Get PIM settings"
# Get all Entra Roles PIM Policies
- $QueryParameters = @{
+ $QueryParameters = @{
'$filter' = "scopeId eq '/' and scopeType eq 'DirectoryRole'"
'$expand' = 'rules'
'$select' = "Id,scopeId,scopeType,rules"
}
$AllPimEntraPolicies = Send-GraphRequest -AccessToken $GLOBALPIMsGraphAccessToken.access_token -Method GET -Uri '/policies/roleManagementPolicies' -QueryParameters $QueryParameters -BetaAPI
-
+
$PimPoliciesCount = $($AllPimEntraPolicies.count)
write-host "[+] Got $PimPoliciesCount PIM settings"
# Get all Entra Roles PIM Role/Policie relations
- $QueryParameters = @{
+ $QueryParameters = @{
'$filter' = "scopeId eq '/' and scopeType eq 'DirectoryRole'"
'$select' = "policyId,scopeId,scopeType,roleDefinitionId"
}
@@ -107,12 +109,12 @@ function Invoke-CheckPIM {
Write-Log -Level Verbose -Message "Got $($AllPimEntraPoliciesAssignments.count) PIM settings relations"
# Get all role names
- $QueryParameters = @{
+ $QueryParameters = @{
'$select' = "id,displayName"
}
- $EntraRolesDefinition = Send-GraphRequest -AccessToken $GLOBALPIMsGraphAccessToken.access_token -Method GET -Uri '/roleManagement/directory/roleDefinitions' -QueryParameters $QueryParameters -BetaAPI
-
- Write-Log -Level Verbose -Message "Got $($EntraRolesDefinition.count) Entra role definitions"
+ $EntraRolesDefinition = Send-GraphRequest -AccessToken $GLOBALPIMsGraphAccessToken.access_token -Method GET -Uri '/roleManagement/directory/roleDefinitions' -QueryParameters $QueryParameters -BetaAPI
+
+ Write-Log -Level Verbose -Message "Got $($EntraRolesDefinition.count) Entra role definitions"
# Create a lookup for role display names
$RoleIdToNameMap = @{}
@@ -140,7 +142,7 @@ function Invoke-CheckPIM {
EligibleAssignments = $eligibleAssignments.Count
}
}
-
+
# Build a lookup for PolicyId -> Rules
$PolicyRulesMap = @{}
@@ -235,7 +237,7 @@ function Invoke-CheckPIM {
if ($ruleMap.ContainsKey("Expiration_EndUser_Assignment")) {
$expirationRule = $ruleMap["Expiration_EndUser_Assignment"]
$durationRaw = $expirationRule.maximumDuration
- $parsedActivationDuration = Parse-ISO8601Duration -DurationString $durationRaw -ReturnUnit 'Hours'
+ $parsedActivationDuration = ConvertFrom-ISO8601Duration -DurationString $durationRaw -ReturnUnit 'Hours'
}
# Extract Expiration_Admin_Eligibility
@@ -249,7 +251,7 @@ function Invoke-CheckPIM {
# Display the value even if it's set, as it doesn't affect the outcome
if ($adminEligibilityEnabled) {
- $parsedAdminEligibilityDuration = Parse-ISO8601Duration -DurationString $adminEligibilityDurationRaw -ReturnUnit 'Days'
+ $parsedAdminEligibilityDuration = ConvertFrom-ISO8601Duration -DurationString $adminEligibilityDurationRaw -ReturnUnit 'Days'
$parsedAdminEligibilityDurationValue = $parsedAdminEligibilityDuration.Value
$parsedAdminEligibilityDurationUnit = $parsedAdminEligibilityDuration.Unit
} else {
@@ -269,14 +271,14 @@ function Invoke-CheckPIM {
# Even if a value is set display - because it does not matter
if ($adminAssignmentEnabled) {
- $parsedAdminAssignmentDuration = Parse-ISO8601Duration -DurationString $adminAssignmentDurationRaw -ReturnUnit 'Days'
+ $parsedAdminAssignmentDuration = ConvertFrom-ISO8601Duration -DurationString $adminAssignmentDurationRaw -ReturnUnit 'Days'
$parsedAdminAssignmentDurationValue = $parsedAdminAssignmentDuration.Value
$parsedAdminAssignmentDurationUnit = $parsedAdminAssignmentDuration.Unit
} else {
$parsedAdminAssignmentDurationValue = "-"
$parsedAdminAssignmentDurationUnit = ""
}
-
+
# Extract Enablement_Admin_Assignment
$adminAssignmentEnabledRules = @()
@@ -303,13 +305,13 @@ function Invoke-CheckPIM {
$type = 'Unknown'
$MemberCount = "-"
- if ($approver.'@odata.type' -match 'groupMembers') {
+ if ($approver.'@odata.type' -match 'groupMembers') {
$type = 'Group'
if ($AllGroupsDetails.ContainsKey($approver.id)) {
$MemberCount = $AllGroupsDetails[$approver.id].Users
}
- } elseif ($approver.'@odata.type' -match 'singleUser') {
- $type = 'User'
+ } elseif ($approver.'@odata.type' -match 'singleUser') {
+ $type = 'User'
}
$approverObj = [PSCustomObject]@{
@@ -393,7 +395,7 @@ function Invoke-CheckPIM {
if ($authCtxEnabled) {
$AuthContextIssues = ""
$AuthContextIssueSummary = [System.Collections.Generic.List[string]]::new()
-
+
# Process each matching policy
$LinkedCaps = @(
$AllCaps.values | Where-Object { $_.AuthContextId -contains $claimValue } | ForEach-Object {
@@ -449,7 +451,7 @@ function Invoke-CheckPIM {
if ($policy.IncPlatforms -gt 0 -and $policy.IncPlatforms -lt 6) {
$Issues.Add("includes specific platforms")
}
-
+
# Check if policy exclude networks
if ($policy.ExcNw -gt 1) {
$Issues.Add("exclude networks")
@@ -483,7 +485,6 @@ function Invoke-CheckPIM {
}
if ($Issues.Count -gt 0) {
- $CapIssues = $true
$AuthContextIssueSummary.Add("CAP '$($policy.DisplayName)' (AuthContext:$($policy.AuthContextId -join ', ')): $($Issues -join ' / ')")
}
@@ -610,7 +611,7 @@ function Invoke-CheckPIM {
#Define output of the main table
$tableOutput = $AllPIMDetails | select-object Role,RoleLink,Tier,Eligible,Active,ActivationAuthContext,ActivationMFA,ActivationJustification,ActivationTicketing,ActivationDuration,ActivationApproval,EligibleExpiration,EligibleExpirationTime,ActiveExpiration,ActiveExpirationTime,ActiveAssignMFA,ActiveAssignJustification,AlertAssignEligible,AlertAssignActive,AlertActivation,Warnings
-
+
#Create HTML main table
$mainTable = $tableOutput | select-object -Property @{Name = "Role"; Expression = { $_.RoleLink}},Tier,Eligible,Active,ActivationAuthContext,ActivationMFA,ActivationJustification,ActivationTicketing,ActivationDuration,ActivationApproval,EligibleExpiration,EligibleExpirationTime,ActiveExpiration,ActiveExpirationTime,ActiveAssignMFA,ActiveAssignJustification,AlertAssignEligible,AlertAssignActive,AlertActivation,Warnings
$mainTableJson = $mainTable | ConvertTo-Json -Depth 5 -Compress
@@ -648,13 +649,13 @@ function Invoke-CheckPIM {
}
[void]$DetailTxtBuilder.AppendLine(($PimRoleSettingInfo| Format-List $TxtReportProps | Out-String))
-
+
############### Activation Settings
- $ActivationSettings = [pscustomobject]@{
+ $ActivationSettings = [pscustomobject]@{
"Activation Max Duration" = "$($item.ActivationDuration) $($item.ActivationDurationUnit)"
"Justification Required" = $item.ActivationJustification
- "Ticket Info Required" = $item.ActivationTicketing
+ "Ticket Info Required" = $item.ActivationTicketing
"MFA Claim Required" = $item.ActivationMFA
"Auth Context Required" = $item.ActivationAuthContext
"Approver Required" = $item.ActivationApproval
@@ -664,7 +665,7 @@ function Invoke-CheckPIM {
[void]$DetailTxtBuilder.AppendLine("Activation Settings")
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine(($ActivationSettings | format-table | Out-String))
-
+
############## Approvers
@@ -678,7 +679,7 @@ function Invoke-CheckPIM {
default { $($object.Description) }
}
- [pscustomobject]@{
+ [pscustomobject]@{
"Type" = $object.Type
"DisplayNameLink" = $DisplayNameLink
"DisplayName" = $object.Description
@@ -689,7 +690,7 @@ function Invoke-CheckPIM {
} else {
#If approvals are required but none are configured
- $ApproversRaw = [pscustomobject]@{
+ $ApproversRaw = [pscustomobject]@{
"Type" = "-"
"DisplayNameLink" = "No approvers configured. Defaulting to Privileged Role Administrators or Global Administrators."
"DisplayName" = "No approvers configured. Defaulting to Privileged Role Administrators or Global Administrators."
@@ -713,7 +714,7 @@ function Invoke-CheckPIM {
if ($item.LinkedCaps -ge 1) {
$LinkedCapsRaw = foreach ($object in $($item.LinkedCapsDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayNameLink" = "$($object.DisplayName)"
"DisplayName" = $object.DisplayName
"AuthContextId" = ($object.AuthContextId -join ', ')
@@ -738,7 +739,7 @@ function Invoke-CheckPIM {
if ($item.EligibleExpiration) {
$MaxEligibleAssignment = "$($item.EligibleExpirationTime) $($item.EligibleExpirationUnit)"
} else {
- $MaxEligibleAssignment = "-"
+ $MaxEligibleAssignment = "-"
}
if ($item.ActiveExpiration) {
@@ -748,7 +749,7 @@ function Invoke-CheckPIM {
}
# $item.EligibleExpiration and $item.ActiveExpiration are inverted due to the wording used in the portal.
- $AssignmentSettings = [pscustomobject]@{
+ $AssignmentSettings = [pscustomobject]@{
"Allow Permanent Eligible Assignment" = !$item.EligibleExpiration
"Expire Eligible Assignments After" = $MaxEligibleAssignment
"Allow Permanent Active Assignment" = !$item.ActiveExpiration
@@ -763,7 +764,7 @@ function Invoke-CheckPIM {
[void]$DetailTxtBuilder.AppendLine(($AssignmentSettings | format-table | Out-String))
- $NotificationSettings = [pscustomobject]@{
+ $NotificationSettings = [pscustomobject]@{
"Alert On Eligible Assignment" = $item.AlertAssignEligible
"Alert On Permanent Assignments" = $item.AlertAssignActive
"Alert On Role Activation" = $item.AlertActivation
@@ -774,7 +775,7 @@ function Invoke-CheckPIM {
[void]$DetailTxtBuilder.AppendLine("================================================================================================")
[void]$DetailTxtBuilder.AppendLine(($NotificationSettings | format-table | Out-String))
- #Build final object
+ #Build final object
$ObjectDetails = [pscustomobject]@{
"Object Name" = $item.Role
"Object ID" = $item.id
@@ -785,7 +786,7 @@ function Invoke-CheckPIM {
"Assignment Settings" = $AssignmentSettings
"Notification Settings" = $NotificationSettings
}
-
+
[void]$AllObjectDetailsHTML.Add($ObjectDetails)
}
@@ -827,7 +828,7 @@ $ObjectsDetailsHEAD = @'
if ($Csv) {
$tableOutput | select-object Role,Tier,Eligible,Active,ActivationAuthContext,ActivationMFA,ActivationJustification,ActivationTicketing,ActivationDuration,ActivationApproval,EligibleExpiration,EligibleExpirationTime,ActiveExpiration,ActiveExpirationTime,ActiveAssignMFA,ActiveAssignJustification,AlertAssignEligible,AlertAssignActive,AlertActivation,Warnings | Export-Csv -Path "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).csv" -NoTypeInformation
}
- $DetailOutputTxt | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
+ $DetailOutputTxt | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).txt" -Append
# Set generic information which get injected into the HTML
Set-GlobalReportManifest -CurrentReportKey 'PIM' -CurrentReportName 'PIM Enumeration'
diff --git a/modules/check_Roles.psm1 b/modules/check_Roles.psm1
index f0f8ac5..5e0d1ae 100644
--- a/modules/check_Roles.psm1
+++ b/modules/check_Roles.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Collects and enriches Entra ID and Azure IAM role assignments, producing output in HTML, TXT, and CSV formats.
#>
@@ -7,6 +7,14 @@ function Invoke-CheckRoles {
############################## Parameter section ########################
[CmdletBinding()]
+ # PSScriptAnalyzer flags these as unused, but they are accessed by the nested
+ # Get-ObjectDetails function via parent scope for cross-referencing object types.
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AdminUnitWithMembers')]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AllGroupsDetails')]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'EnterpriseApps')]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ManagedIdentities')]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AppRegistrations')]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Users')]
Param (
[Parameter(Mandatory=$false)][string]$OutputFolder = ".",
[Parameter(Mandatory=$false)][Object[]]$AdminUnitWithMembers,
@@ -39,7 +47,7 @@ function Invoke-CheckRoles {
if ($normalizedType -eq "unknown" -or $normalizedType -eq "user") {
$MatchingUser = $Users[$($ObjectID)]
if ($MatchingUser) {
- $object = [PSCustomObject]@{
+ $object = [PSCustomObject]@{
DisplayName = $MatchingUser.UPN
DisplayNameLink = "$($MatchingUser.UPN)"
Type = "User"
@@ -52,20 +60,20 @@ function Invoke-CheckRoles {
if ($normalizedType -eq "unknown" -or $normalizedType -eq "group" ) {
$MatchingGroup = $AllGroupsDetails[$($ObjectID)]
if ($MatchingGroup) {
- $object = [PSCustomObject]@{
+ $object = [PSCustomObject]@{
DisplayName = $MatchingGroup.DisplayName
DisplayNameLink = "$($MatchingGroup.DisplayName)"
Type = "Group"
}
$ObjectDetailsCache[$cacheKey] = $object
Return $object
- }
+ }
}
if ($normalizedType -eq "unknown" -or $normalizedType -eq "serviceprincipal") {
$MatchingEnterpriseApp = $EnterpriseApps[$($ObjectID)]
if ($MatchingEnterpriseApp) {
- $object = [PSCustomObject]@{
+ $object = [PSCustomObject]@{
DisplayName = $MatchingEnterpriseApp.DisplayName
DisplayNameLink = "$($MatchingEnterpriseApp.DisplayName)"
Type = "Enterprise Application"
@@ -78,7 +86,7 @@ function Invoke-CheckRoles {
if ($normalizedType -eq "unknown" -or $normalizedType -eq "managedidentity" -or $normalizedType -eq "serviceprincipal") {
$MatchingManagedIdentity = $ManagedIdentities[$($ObjectID)]
if ($MatchingManagedIdentity) {
- $object = [PSCustomObject]@{
+ $object = [PSCustomObject]@{
DisplayName = $MatchingManagedIdentity.DisplayName
DisplayNameLink = "$($MatchingManagedIdentity.DisplayName)"
Type = "Managed Identity"
@@ -87,11 +95,11 @@ function Invoke-CheckRoles {
Return $object
}
}
-
+
if ($normalizedType -eq "unknown" -or $normalizedType -eq "AppRegistration" ) {
$MatchingAppRegistration = $AppRegistrations[$($ObjectID)]
if ($MatchingAppRegistration) {
- $object = [PSCustomObject]@{
+ $object = [PSCustomObject]@{
DisplayName = $MatchingAppRegistration.DisplayName
DisplayNameLink = "$($MatchingAppRegistration.DisplayName)"
Type = "App Registration"
@@ -100,11 +108,11 @@ function Invoke-CheckRoles {
Return $object
}
}
-
+
if ($normalizedType -eq "unknown" -or $normalizedType -eq "administrativeunit") {
$MatchingAdministrativeUnit = $AdminUnitWithMembers | Where-Object { $_.AuId -eq $ObjectID }
if ($MatchingAdministrativeUnit) {
- $object = [PSCustomObject]@{
+ $object = [PSCustomObject]@{
DisplayName = $MatchingAdministrativeUnit.DisplayName
DisplayNameLink = $MatchingAdministrativeUnit.DisplayName
Type = "Administrative Unit"
@@ -118,7 +126,7 @@ function Invoke-CheckRoles {
# Fallback: resolve unknown objects via directoryObjects/getByIds (expensive but should be OK with caching)
if ($normalizedType -eq "unknown" -or $normalizedType -like "*foreign*") {
-
+
Write-Log -Level Trace -Message "Manually resolve $ObjectID"
# Not sure if device make sense, but the Azure Portal use it as well
$Body = @{
@@ -266,7 +274,7 @@ function Invoke-CheckRoles {
#Unknown Object
if ($normalizedType -eq "unknown") {
- $object = [PSCustomObject]@{
+ $object = [PSCustomObject]@{
DisplayName = $ObjectID
DisplayNameLink = $ObjectID
Type = "Unknown Object"
@@ -320,7 +328,7 @@ function Invoke-CheckRoles {
3 {$RoleTier = "Tier-3"; break}
"?" {$RoleTier = "Uncategorized"}
}
- [pscustomobject]@{
+ [pscustomobject]@{
"Role" = $($item.DisplayName)
"PrincipalId" = $($item.PrincipalId)
"PrincipalDisplayName" = $($PrincipalDetails.DisplayName)
@@ -538,7 +546,7 @@ $headerHtml = @"
$SortedEntraRoles | select-object Role,RoleTier,IsPrivileged,IsBuiltIn,AssignmentType, PrincipalDisplayName, PrincipalType,ScopeResolved | Export-Csv -Path "$outputFolder\$($Title)_Entra_$($StartTimestamp)_$($CurrentTenant.DisplayName).csv" -NoTypeInformation
}
$OutputFormats = if ($Csv) { "CSV,TXT,HTML" } else { "TXT,HTML" }
- write-host "[+] Details of $($SortedEntraRoles.count) Entra ID role assignments stored in output files ($OutputFormats): $outputFolder\$($Title)_Entra_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
+ write-host "[+] Details of $($SortedEntraRoles.count) Entra ID role assignments stored in output files ($OutputFormats): $outputFolder\$($Title)_Entra_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
#Add information to the enumeration summary
$EntraEligibleCount = 0
@@ -593,7 +601,7 @@ $headerHtml = @"
$GlobalAuditSummary.EntraRoleAssignments.Tiers."Tier-1" = $Tier1Count
$GlobalAuditSummary.EntraRoleAssignments.Tiers."Tier-2" = $Tier2Count
$GlobalAuditSummary.EntraRoleAssignments.Tiers.Uncategorized = $TierUncatCount
-
+
if ($SortedAzureRoles.count -ge 1) {
@@ -606,7 +614,7 @@ $headerHtml = @"
$SortedAzureRoles | select-object Scope,Role,RoleTier,RoleType,Conditions,AssignmentType,PrincipalDisplayName,PrincipalType | Export-Csv -Path "$outputFolder\$($Title)_Azure_$($StartTimestamp)_$($CurrentTenant.DisplayName).csv" -NoTypeInformation
}
write-host "[+] Details of $($SortedAzureRoles.count) Azure role assignments stored in output files ($OutputFormats): $outputFolder\$($Title)_Azure_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
-
+
#Add information to the enumeration summary
$AzureEligibleCount = 0
$AzureTier0Count = 0
diff --git a/modules/check_Tenant.psm1 b/modules/check_Tenant.psm1
index e55226b..e2a7de3 100644
--- a/modules/check_Tenant.psm1
+++ b/modules/check_Tenant.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Generates the security findings HTML report.
@@ -1522,7 +1522,7 @@ Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$falseRefer
Severity = 0
Threat = "
If attackers gain control over an application with consented permissions, they may leverage even limited access to facilitate further attacks.
"
Remediation = 'Consider restricting application consent to administrators only. Configure the following setting in the Entra admin portal:
- Select Enterprise Applications
- Select Consent and permissions
- Select
Do not allow user consent
References:
'
-
+
Confidence = "Requires Verification"
}
}
@@ -3908,7 +3908,7 @@ Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$falseRefer
if ($startText -match "^\d{4}-\d{2}-\d{2}") {
$start = $startText.Substring(0, 10)
} else {
- try { $start = ([datetime]$startText).ToString("yyyy-MM-dd") } catch {}
+ try { $start = ([datetime]$startText).ToString("yyyy-MM-dd") } catch { Write-Verbose "Could not parse start date: $startText" }
}
}
}
@@ -3920,7 +3920,7 @@ Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false
Refer
if ($endText -match "^\d{4}-\d{2}-\d{2}") {
$end = $endText.Substring(0, 10)
} else {
- try { $end = ([datetime]$endText).ToString("yyyy-MM-dd") } catch {}
+ try { $end = ([datetime]$endText).ToString("yyyy-MM-dd") } catch { Write-Verbose "Could not parse end date: $endText" }
}
}
}
@@ -4225,8 +4225,8 @@ Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false
Refer
$consentInfo = " (All users)"
} elseif ($perm.ConsentType -eq "Principal") {
$consentCount = $perm.ConsentCount
- if ($consentCount -eq $null) { $consentCount = $perm.PrincipalCount }
- if ($consentCount -eq $null) {
+ if ($null -eq $consentCount) { $consentCount = $perm.PrincipalCount }
+ if ($null -eq $consentCount) {
$consentInfo = " (some users)"
} elseif ($consentCount -is [string] -and $consentCount -match "\busers?\b") {
$consentInfo = " ($consentCount)"
@@ -4669,8 +4669,8 @@ Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false
Refer
$consentInfo = " (All users)"
} elseif ($perm.ConsentType -eq "Principal") {
$consentCount = $perm.ConsentCount
- if ($consentCount -eq $null) { $consentCount = $perm.PrincipalCount }
- if ($consentCount -eq $null) {
+ if ($null -eq $consentCount) { $consentCount = $perm.PrincipalCount }
+ if ($null -eq $consentCount) {
$consentInfo = " (some users)"
} elseif ($consentCount -is [string] -and $consentCount -match "\busers?\b") {
$consentInfo = " ($consentCount)"
@@ -5019,7 +5019,7 @@ Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false
Refer
if ($startText -match "^\d{4}-\d{2}-\d{2}") {
$start = $startText.Substring(0, 10)
} else {
- try { $start = ([datetime]$startText).ToString("yyyy-MM-dd") } catch {}
+ try { $start = ([datetime]$startText).ToString("yyyy-MM-dd") } catch { Write-Verbose "Could not parse start date: $startText" }
}
}
}
@@ -5031,7 +5031,7 @@ Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false
Refer
if ($endText -match "^\d{4}-\d{2}-\d{2}") {
$end = $endText.Substring(0, 10)
} else {
- try { $end = ([datetime]$endText).ToString("yyyy-MM-dd") } catch {}
+ try { $end = ([datetime]$endText).ToString("yyyy-MM-dd") } catch { Write-Verbose "Could not parse end date: $endText" }
}
}
}
@@ -6397,7 +6397,7 @@ Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false
Refer
if ($policyIds -contains "ManagePermissionGrantsForSelf.microsoft-user-default-recommended") {
Write-Log -Level Trace -Message "[USR-004] User consent policy: Microsoft managed."
Set-FindingOverride -FindingId "USR-004" -Props $USR004VariantProps.MicrosoftManaged
-
+
# Low policy: evaluate classified permissions to identify extensive scopes.
} elseif ($policyIds -contains "ManagePermissionGrantsForSelf.microsoft-user-default-low") {
Write-Log -Level Trace -Message "[USR-004] User consent policy: low with classified permissions."
diff --git a/modules/check_Users.psm1 b/modules/check_Users.psm1
index 81a73a2..d82bd53 100644
--- a/modules/check_Users.psm1
+++ b/modules/check_Users.psm1
@@ -1,10 +1,12 @@
-<#
+<#
.SYNOPSIS
Enumerates and analyzes all users in the current tenant, including access, ownerships, roles, and risk posture.
#>
function Invoke-CheckUsers {
############################## Parameter section ########################
+ # ConditionalAccessPolicies is passed by the shared calling interface but not consumed by this module
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ConditionalAccessPolicies')]
[CmdletBinding()]
Param (
[Parameter(Mandatory=$false)][string]$OutputFolder = ".",
@@ -64,7 +66,7 @@ function Invoke-CheckUsers {
"DirectAppRoleSensitive" = 50
"SpOwnAppLock" = 20
}
-
+
$UserLikelihood = @{
"Base" = 5
"SyncedFromOnPrem" = 3
@@ -123,24 +125,24 @@ function Invoke-CheckUsers {
foreach ($au in $AdminUnitWithMembers) {
$members = $au.MembersUser
-
+
if ($members -is [System.Collections.IDictionary]) {
$members = @($members)
}
-
+
foreach ($member in $members) {
$id = $member.id
if ($null -ne $id) {
if (-not $UserToAUMap.ContainsKey($id)) {
$UserToAUMap[$id] = [System.Collections.Generic.List[object]]::new()
}
-
+
# Store only required properties
$auLite = [pscustomobject]@{
DisplayName = $au.DisplayName
IsMemberManagementRestricted = $au.IsMemberManagementRestricted
}
-
+
$UserToAUMap[$id].Add($auLite)
}
}
@@ -181,7 +183,7 @@ function Invoke-CheckUsers {
$QueryParameters = @{
'$select' = "Id,DisplayName,UserPrincipalName,AccountEnabled,UserType,AssignedLicenses,OtherMails,OnPremisesSyncEnabled,CreatedDateTime,JobTitle,Department,perUserMfaState"
'$top' = $ApiTop
- }
+ }
}
$AllUsers = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/users" -QueryParameters $QueryParameters -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name)
@@ -192,11 +194,11 @@ function Invoke-CheckUsers {
Write-Host "[*] Collecting user memberships"
$UserMemberOfRaw = @{}
- $BatchSize = 10000
+ $BatchSize = 10000
$ChunkCount = [math]::Ceiling($AllUsers.Count / $BatchSize)
for ($chunkIndex = 0; $chunkIndex -lt $ChunkCount; $chunkIndex++) {
- Write-Log -Level Verbose -Message "Processing user batch $($chunkIndex + 1) of $ChunkCount..."
+ Write-Log -Level Verbose -Message "Processing user batch $($chunkIndex + 1) of $ChunkCount..."
$StartIndex = $chunkIndex * $BatchSize
$EndIndex = [math]::Min($StartIndex + $BatchSize - 1, $AllUsers.Count - 1)
@@ -232,7 +234,7 @@ function Invoke-CheckUsers {
}
}
}
-
+
# Count transitive memberships
$TotalTransitiveMemberRelations = 0
@@ -283,7 +285,7 @@ function Invoke-CheckUsers {
"id" = $item.id
"method" = "GET"
"url" = "/users/$($item.id)/ownedDevices"
- "headers" = @{"Accept"= "application/json;odata.metadata=none"}
+ "headers" = @{"Accept"= "application/json;odata.metadata=none"}
}
$Requests.Add($req)
}
@@ -307,7 +309,7 @@ function Invoke-CheckUsers {
"id" = $item.id
"method" = "GET"
"url" = "/users/$($item.id)/registeredDevices"
- "headers" = @{"Accept"= "application/json;odata.metadata=none"}
+ "headers" = @{"Accept"= "application/json;odata.metadata=none"}
}
$Requests.Add($req)
}
@@ -318,7 +320,7 @@ function Invoke-CheckUsers {
if ($item.response.value -and $item.response.value.Count -gt 0) {
$DeviceRegisteredRaw[$item.id] = $item.response.value
}
- }
+ }
$PmDataCollection.Stop()
########################################## SECTION: User Processing ##########################################
@@ -358,7 +360,7 @@ function Invoke-CheckUsers {
$Inactive = $false
$UserEntraRoles = @()
$Agent = $item.'@odata.type' -eq '#microsoft.graph.agentUser'
-
+
# Check the token lifetime after a specific amount of objects
if (($ProgressCounter % $TokenCheckLimit) -eq 0 -and $SkipAutoRefresh -eq $false) {
if (-not (Invoke-CheckTokenExpiration $GLOBALmsGraphAccessToken)) { RefreshAuthenticationMsGraph | Out-Null}
@@ -412,7 +414,7 @@ function Invoke-CheckUsers {
if ($UserOwnedObjectsRaw.ContainsKey($item.Id)) {
foreach ($OwnedObject in $UserOwnedObjectsRaw[$item.Id]) {
switch ($OwnedObject.'@odata.type') {
-
+
'#microsoft.graph.servicePrincipal' {
[void]$UserOwnedSP.Add(
[PSCustomObject]@{
@@ -420,7 +422,7 @@ function Invoke-CheckUsers {
}
)
}
-
+
'#microsoft.graph.application' {
[void]$UserOwnedAppRegs.Add(
[PSCustomObject]@{
@@ -430,7 +432,7 @@ function Invoke-CheckUsers {
}
'#microsoft.graph.agentIdentity' {
- Write-Log -Level Trace -Message "The user $($user.Id) owns the AgentIdentity $($OwnedObject.Id)"
+ Write-Log -Level Trace -Message "The user $($item.Id) owns the AgentIdentity $($OwnedObject.Id)"
[void]$UserOwnedAgentIdentitys.Add(
[PSCustomObject]@{
Id = $OwnedObject.Id
@@ -439,7 +441,7 @@ function Invoke-CheckUsers {
}
'#microsoft.graph.agentIdentityBlueprint' {
- Write-Log -Level Trace -Message "The user $($user.Id) owns the AgentIdentityBlueprint $($OwnedObject.Id)"
+ Write-Log -Level Trace -Message "The user $($item.Id) owns the AgentIdentityBlueprint $($OwnedObject.Id)"
[void]$UserOwnedAgentIdentityBlueprint.Add(
[PSCustomObject]@{
Id = $OwnedObject.Id
@@ -447,14 +449,14 @@ function Invoke-CheckUsers {
)
}
'#microsoft.graph.agentIdentityBlueprintPrincipal' {
- Write-Log -Level Trace -Message "The user $($user.Id) owns the AgentIdentityBlueprintPrincipal $($OwnedObject.Id)"
+ Write-Log -Level Trace -Message "The user $($item.Id) owns the AgentIdentityBlueprintPrincipal $($OwnedObject.Id)"
[void]$UserOwnedAgentIdentityBlueprint.Add(
[PSCustomObject]@{
Id = $OwnedObject.Id
}
)
}
-
+
'#microsoft.graph.group' {
[void]$UserOwnedGroups.Add(
[PSCustomObject]@{
@@ -463,9 +465,9 @@ function Invoke-CheckUsers {
}
)
}
-
+
default {
- Write-Log -Level Debug -Message "Unknown owned object type: $($OwnedObject.'@odata.type') for user $($user.Id)"
+ Write-Log -Level Debug -Message "Unknown owned object type: $($OwnedObject.'@odata.type') for user $($item.Id)"
}
}
}
@@ -481,7 +483,7 @@ function Invoke-CheckUsers {
}
)
}
- }
+ }
#Get users registered devices
$DeviceRegistered = [System.Collections.Generic.List[object]]::new()
@@ -493,7 +495,7 @@ function Invoke-CheckUsers {
}
)
}
- }
+ }
if ($TenantPimForGroupsAssignments) {
if ($UserGroupMapping.ContainsKey($item.Id)) {
@@ -527,7 +529,7 @@ function Invoke-CheckUsers {
}
if (@($MatchingEnterpriseApp).count -ge 1) {
- [PSCustomObject]@{
+ [PSCustomObject]@{
Id = $MatchingEnterpriseApp.Id
DisplayName = $MatchingEnterpriseApp.DisplayName
AppLock = $AppLock
@@ -551,7 +553,7 @@ function Invoke-CheckUsers {
$AppRegOwnerDetails = foreach ($object in $UserOwnedAppRegs) {
$MatchingAppReg = $AppRegistrations[$($Object.id)]
if (@($MatchingAppReg).count -ge 1) {
- [PSCustomObject]@{
+ [PSCustomObject]@{
Id = $MatchingAppReg.Id
DisplayName = $MatchingAppReg.DisplayName
SignInAudience = $MatchingAppReg.SignInAudience
@@ -566,7 +568,7 @@ function Invoke-CheckUsers {
foreach ($object in $UserOwnedGroups) {
$MatchingGroup = $AllGroupsDetails[$($Object.id)]
if ($MatchingGroup) {
- [void]$GroupOwnerDetails.Add([PSCustomObject]@{
+ [void]$GroupOwnerDetails.Add([PSCustomObject]@{
Id = $object.Id
AssignmentType = $object.AssignmentType
RoleAssignable = $MatchingGroup.RoleAssignable
@@ -590,7 +592,7 @@ function Invoke-CheckUsers {
$MatchingGroup = $AllGroupsDetails[$($Object.id)]
if ($MatchingGroup) {
- [void]$GroupMemberDetails.Add([PSCustomObject]@{
+ [void]$GroupMemberDetails.Add([PSCustomObject]@{
Id = $object.Id
AssignmentType = $object.AssignmentType
RoleAssignable = $MatchingGroup.RoleAssignable
@@ -603,7 +605,7 @@ function Invoke-CheckUsers {
Impact = $MatchingGroup.Impact
})
}
- }
+ }
#Sort by impact
$GroupMemberDetails = $GroupMemberDetails | Sort-Object -Property Impact -Descending
@@ -611,8 +613,8 @@ function Invoke-CheckUsers {
$UserDirectAppRoles = $GLOBALUserAppRoles[$item.Id]
if ($null -eq $UserDirectAppRoles) {
$UserDirectAppRolesCount = 0
- } else {
- $UserDirectAppRolesCount = @($UserDirectAppRoles).Count
+ } else {
+ $UserDirectAppRolesCount = @($UserDirectAppRoles).Count
}
@@ -637,13 +639,13 @@ function Invoke-CheckUsers {
# Per-user MFA state from /users endpoint
$PerUserMfa = if ([string]::IsNullOrWhiteSpace([string]$item.perUserMfaState)) { "-" } else { [string]$item.perUserMfaState }
- ########################################## SECTION: RISK RATING AND WARNINGS ##########################################
+ ########################################## SECTION: RISK RATING AND WARNINGS ##########################################
#Increase the risk score if user is not MFA capable and is not the sync account and not an AgentUser
if ($IsMfaCapable -ne "?" -and $IsMfaCapable -ne $true -and $item.DisplayName -ne "On-Premises Directory Synchronization Service Account" -and -not $item.Agent) {
$Likelihood += $UserLikelihood["NoMFA"]
}
-
+
#Process owned SP
if ($SPOwnerDetails) {
#Add the impact score of the owned SP
@@ -714,7 +716,7 @@ function Invoke-CheckUsers {
if ($object.AzureRoles -is [int]) {$AzureRolesCount += $object.AzureRoles} else {$AzureRolesCount += 0}
$EntraMaxTierTroughGroupOwnership = Merge-HigherTierLabel -CurrentTier $EntraMaxTierTroughGroupOwnership -CandidateTier $object.EntraMaxTier
$AzureMaxTierTroughGroupOwnership = Merge-HigherTierLabel -CurrentTier $AzureMaxTierTroughGroupOwnership -CandidateTier $object.AzureMaxTier
-
+
$AppRolesCount += $object.AppRoles
}
$Impact += $AddImpact
@@ -766,7 +768,7 @@ function Invoke-CheckUsers {
if ($GLOBALPermissionForCaps -and $object.CAPs -ge 1) {
$ObjectsWithCaps++
}
-
+
if ($object.AzureRoles -is [int]) {$AzureRolesCount += $object.AzureRoles} else {$AzureRolesCount += 0}
$EntraMaxTierTroughGroupMembership = Merge-HigherTierLabel -CurrentTier $EntraMaxTierTroughGroupMembership -CandidateTier $object.EntraMaxTier
$AzureMaxTierTroughGroupMembership = Merge-HigherTierLabel -CurrentTier $AzureMaxTierTroughGroupMembership -CandidateTier $object.AzureMaxTier
@@ -817,10 +819,10 @@ function Invoke-CheckUsers {
$SyncAcc = $false
$CloudSyncAccount = $false
}
-
+
# Find matching roles in Entra role assignments where the PrincipalId matches the user's Id
$MatchingEntraRoles = $TenantRoleAssignments[$item.Id]
- foreach ($Role in $MatchingEntraRoles) {
+ foreach ($Role in $MatchingEntraRoles) {
$Roleinfo = [PSCustomObject]@{
DisplayName = $role.DisplayName
@@ -856,7 +858,7 @@ function Invoke-CheckUsers {
[void]$Warnings.Add("Directory Synchronization Role on non-sync user!")
}
}
-
+
# Check app roles for sensitive keywords
if ($UserDirectAppRolesCount -ge 1) {
$keywords = @("admin", "critical")
@@ -955,7 +957,7 @@ function Invoke-CheckUsers {
} else {
''
}
-
+
#Combine Direct assigned Entra roles + roles trough group
$TotalEntraRoles = $EntraRolesTroughGroupOwnership + $EntraRolesTroughGroupMembership + @($UserEntraRoles).count
@@ -964,14 +966,14 @@ function Invoke-CheckUsers {
} else {
$TotalAzureRoles = $AzureRoleCount
}
-
-
+
+
#Calc risk
$Risk = [math]::Round(($Impact * $Likelihood))
#Create custom object
- $UserDetails = [PSCustomObject]@{
- Id = $item.Id
+ $UserDetails = [PSCustomObject]@{
+ Id = $item.Id
DisplayName = $item.DisplayName
UPNlink = "$($item.UserPrincipalName)"
UPN = $item.UserPrincipalName
@@ -1022,9 +1024,9 @@ function Invoke-CheckUsers {
Likelihood = [math]::Round($Likelihood,1)
Risk = $Risk
Warnings = $Warnings
- }
+ }
+
-
[void]$AllUsersDetails.Add($UserDetails)
@@ -1038,7 +1040,7 @@ function Invoke-CheckUsers {
#Define output of the main table
$tableOutput = $AllUsersDetails | Sort-Object Risk -Descending | select-object UPN,UPNlink,Enabled,UserType,Agent,OnPrem,Licenses,LicenseStatus,Protected,GrpMem,GrpOwn,AuUnits,EntraRoles,EntraMaxTier,AzureRoles,AzureMaxTier,AppRoles,AppRegOwn,SPOwn,DeviceOwn,DeviceReg,Inactive,LastSignInDays,CreatedDays,MfaCap,PerUserMfa,Impact,Likelihood,Risk,Warnings
-
+
# Apply result limit for the main table
if ($LimitResults -and $LimitResults -gt 0) {
$tableOutput = $tableOutput | Select-Object -First $LimitResults
@@ -1072,7 +1074,7 @@ function Invoke-CheckUsers {
$detailsCount = $details.count
$StatusUpdateInterval = [Math]::Max([Math]::Floor($detailsCount / 10), 1)
Write-Log -Level Verbose -Message "Status: Processing user 1 of $detailsCount (updates every $StatusUpdateInterval users)..."
- $ProgressCounter = 0
+ $ProgressCounter = 0
#Enum the details
foreach ($item in $details) {
@@ -1165,10 +1167,10 @@ function Invoke-CheckUsers {
[void]$DetailTxtBuilder.AppendLine()
}
-
+
if (@($item.RolesDetails).count -ge 1) {
$ReportingRoles = foreach ($role in $($item.RolesDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role name" = $role.DisplayName
"AssignmentType" = $role.AssignmentType
"Tier Level" = $role.RoleTier
@@ -1203,7 +1205,7 @@ function Invoke-CheckUsers {
$maxWarningsLength = $warnings.Length
}
- [pscustomobject]@{
+ [pscustomobject]@{
"AssignmentType" = $object.AssignmentType
"DisplayName" = $displayName
"DisplayNameLink" = "$($displayName)"
@@ -1226,8 +1228,8 @@ function Invoke-CheckUsers {
-Properties @("AssignmentType", "Displayname", "Type", "OnPrem", "EntraRoles", "EntraMaxTier", "AzureRoles", "AzureMaxTier", "AppRoles", "CAPs", "Users", "Impact", "Warnings") `
-ColumnWidths @{ AssignmentType = 15; Displayname = [Math]::Min($maxDisplayNameLength, 60); Type = 15; OnPrem = 7; EntraRoles = 10; EntraMaxTier = 11; AzureRoles = 10; AzureMaxTier = 11; AppRoles = 8; CAPs = 4; Users = 5; Impact = 6; Warnings = [Math]::Min($maxWarningsLength, 60) }
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
-
+
+
$ReportingGroupOwner = foreach ($obj in $ReportingGroupOwner) {
[pscustomobject]@{
AssignmentType = $obj.AssignmentType
@@ -1249,13 +1251,13 @@ function Invoke-CheckUsers {
if (@($item.AppRegOwnerDetails).count -ge 1) {
$ReportingOwnerAppRegistration = foreach ($app in $($item.AppRegOwnerDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $app.DisplayName
"DisplayNameLink" = "$($app.DisplayName)"
"SignInAudience" = $app.SignInAudience
"AppRoles" = $app.AppRoles
"Impact" = $app.Impact
- }
+ }
}
#Sort based on the impact
$ReportingOwnerAppRegistration = $ReportingOwnerAppRegistration | Sort-Object -Property Impact -Descending
@@ -1277,7 +1279,7 @@ function Invoke-CheckUsers {
if (@($item.SPOwnerDetails).count -ge 1) {
$ReportingOwnerSP = foreach ($app in $($item.SPOwnerDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"DisplayName" = $app.DisplayName
"DisplayNameLink" = "$($app.DisplayName)"
"AppLock" = $app.AppLock
@@ -1327,13 +1329,13 @@ function Invoke-CheckUsers {
$OsLength = $Os.Length
}
- [pscustomobject]@{
+ [pscustomobject]@{
"Displayname" = $DiplayName
"Type" = $DeviceDetails.trustType
"OS" = $Os
}
}
-
+
# Build TXT
$formattedText = Format-ReportSection -Title "Owner of Devices" `
-Objects $ReportingOwnerDevice `
@@ -1361,7 +1363,7 @@ function Invoke-CheckUsers {
$OsLength = $Os.Length
}
- [pscustomobject]@{
+ [pscustomobject]@{
"Displayname" = $DiplayName
"Type" = $DeviceDetails.trustType
"OS" = $Os
@@ -1374,15 +1376,15 @@ function Invoke-CheckUsers {
-Properties @("Displayname", "Type", "OS") `
-ColumnWidths @{ Displayname = [Math]::Min($DiplayNameLength, 30); Type = 15; OS = [Math]::Min($OsLength, 40) }
[void]$DetailTxtBuilder.AppendLine($formattedText)
- }
+ }
#AU Devices
- if (@($item.AUMemberDetails).count -ge 1) {
+ if (@($item.AUMemberDetails).count -ge 1) {
$ReportingAdminUnits = foreach ($Au in $($item.AUMemberDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"AU Name" = $Au.DisplayName
"isMemberManagementRestricted" = $Au.isMemberManagementRestricted
-
+
}
}
@@ -1399,7 +1401,7 @@ function Invoke-CheckUsers {
$maxAppRoleNameLength = 0
$maxDescriptionLength = 0
$maxAppNameLength = 0
-
+
$ReportingAppRoles = foreach ($object in $($item.AppRolesDetails)) {
$AppRoleName = $object.AppRoleDisplayName
@@ -1414,7 +1416,7 @@ function Invoke-CheckUsers {
if ($null -ne $AppName -and $AppName.Length -gt $maxAppNameLength) {
$maxAppNameLength = $AppName.Length
}
- [pscustomobject]@{
+ [pscustomobject]@{
"AppRoleName" = $AppRoleName
"Enabled" = $object.AppRoleEnabled
"Description" = $Description
@@ -1422,7 +1424,7 @@ function Invoke-CheckUsers {
"App" = $AppName
}
}
-
+
$formattedText = Format-ReportSection -Title "Directly Assigned AppRoles" `
-Objects $ReportingAppRoles `
-Properties @("AppRoleName", "Enabled", "Description", "App") `
@@ -1453,7 +1455,7 @@ function Invoke-CheckUsers {
foreach ($object in $($item.UserMemberGroups)) {
$MatchingGroup = $AllGroupsDetails[$($Object.id)]
-
+
#Calculate field size for displayname and warnings. This allow the reduce of whitespaces in combination with Format-ReportSection
$displayName = $MatchingGroup.DisplayName
$warnings = $MatchingGroup.Warnings
@@ -1488,7 +1490,7 @@ function Invoke-CheckUsers {
-Properties @("AssignmentType", "Displayname", "Type", "OnPrem", "EntraRoles", "EntraMaxTier", "AzureRoles", "AzureMaxTier", "AppRoles", "CAPs", "Users", "Impact", "Warnings") `
-ColumnWidths @{ AssignmentType = 15; Displayname = [Math]::Min($maxDisplayNameLength, 60); Type = 15; OnPrem = 7; EntraRoles = 10; EntraMaxTier = 11; AzureRoles = 10; AzureMaxTier = 11; AppRoles = 8; CAPs = 4; Users = 5; Impact = 6; Warnings = [Math]::Min($maxWarningsLength, 60) }
[void]$DetailTxtBuilder.AppendLine($formattedText)
-
+
foreach ($obj in $MatchingGroupRaw) {
$ReportingMemberGroup.Add([pscustomobject]@{
AssignmentType = $obj.AssignmentType
@@ -1512,7 +1514,7 @@ function Invoke-CheckUsers {
############### Azure Roles
if ($item.AzureRoles -ge 1 ) {
$ReportingAzureRoles = foreach ($object in $($item.AzureRoleDetails)) {
- [pscustomobject]@{
+ [pscustomobject]@{
"Role name" = $object.RoleName
"Assignment" = $object.AssignmentType
"RoleType" = $object.RoleType
@@ -1543,7 +1545,7 @@ function Invoke-CheckUsers {
"Member of Groups (Transitive)" = $ReportingMemberGroup
"Azure IAM assignments" = $ReportingAzureRoles
}
-
+
[void]$AllObjectDetailsHTML.Add($ObjectDetails)
}
@@ -1603,7 +1605,7 @@ $headerHtml = @"
$OutputFormats = if ($Csv) { "CSV,TXT,HTML" } else { "TXT,HTML" }
write-host "[+] Details of $($tableOutput.count) users stored in output files ($OutputFormats): $outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName)"
-
+
#Write HTML
$Report = ConvertTo-HTML -Body "$headerHTML $mainTableHTML" -Title "$Title enumeration" -Head ($global:GLOBALReportManifestScript + $global:GLOBALCss) -PostContent $GLOBALJavaScript -PreContent $AllObjectDetailsHTML
$Report | Out-File "$outputFolder\$($Title)_$($StartTimestamp)_$($CurrentTenant.DisplayName).html"
diff --git a/modules/export_Summary.psm1 b/modules/export_Summary.psm1
index 9ab0a27..3768f0b 100644
--- a/modules/export_Summary.psm1
+++ b/modules/export_Summary.psm1
@@ -1,4 +1,4 @@
-<#
+<#
.SYNOPSIS
Generate a summary about the enumerated objects in the tenant.
@@ -23,7 +23,7 @@ function Export-Summary {
for ($i = 1; $i -le $ChartCount; $i++) {
$charts += "
`n"
}
-
+
return @"