From ae2bdaa40c4c5597de4289822d9502ed15c6312b Mon Sep 17 00:00:00 2001 From: StrongWind1 <5987034+StrongWind1@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:43:37 -0400 Subject: [PATCH 1/6] Remove trailing whitespace from all script files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run PSScriptAnalyzer with -Fix to automatically remove 695 instances of trailing whitespace across all .psm1 and .ps1 files. Rule: PSAvoidTrailingWhitespace (Information) No functional changes — whitespace-only diff. --- modules/EntraTokenAid.psm1 | 192 ++++++++-------- modules/Send-ApiRequest.psm1 | 4 +- modules/Send-GraphBatchRequest.psm1 | 22 +- modules/Send-GraphRequest.psm1 | 20 +- modules/check_AppRegistrations.psm1 | 68 +++--- modules/check_CAPs.psm1 | 140 ++++++------ modules/check_EnterpriseApps.psm1 | 176 +++++++-------- modules/check_Groups.psm1 | 322 +++++++++++++-------------- modules/check_ManagedIdentities.psm1 | 72 +++--- modules/check_PIM.psm1 | 60 ++--- modules/check_Roles.psm1 | 32 +-- modules/check_Tenant.psm1 | 6 +- modules/check_Users.psm1 | 132 +++++------ modules/export_Summary.psm1 | 12 +- modules/shared_Functions.psm1 | 160 ++++++------- run_EntraFalcon.ps1 | 12 +- 16 files changed, 715 insertions(+), 715 deletions(-) diff --git a/modules/EntraTokenAid.psm1 b/modules/EntraTokenAid.psm1 index b56d397..f17450a 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` @@ -262,7 +262,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 +273,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 +286,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 +361,7 @@ function Invoke-Auth { } } } - + #Spawn local HTTP server to catch the auth code if ($AuthMode -eq "LocalHTTP") { @@ -380,7 +380,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 +394,7 @@ function Invoke-Auth { } break } - + # Process output from the shared queue $Request = $null while ($RequestQueue.TryDequeue([ref]$Request) -and $Proceed) { @@ -405,12 +405,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 +418,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 +439,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 +480,7 @@ function Invoke-Auth { } } - + } finally { #Cleaning up Write-Host "[*] Stopping the server..." @@ -536,7 +536,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 +553,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 +570,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 +595,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 +609,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 +622,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 +651,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 +667,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 +695,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 +710,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 +747,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 +774,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 +789,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 +825,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 +833,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 +857,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 +871,7 @@ function Invoke-Refresh { } elseif($Proceed) { Write-Host "[!] The answer obtained from the token endpoint do not contains tokens" } - + } function Invoke-DeviceCodeFlow { @@ -880,12 +880,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 +895,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 +918,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 +948,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 +967,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 +980,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 +1050,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 +1073,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 +1100,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 +1108,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 +1116,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 +1131,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 +1150,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 +1167,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 +1183,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 +1222,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 +1239,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 +1254,7 @@ function Invoke-ClientCredential { } } - Return $TokensClientCredential + Return $TokensClientCredential } function Invoke-ParseJwt { @@ -1263,7 +1263,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 +1286,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 +1321,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 +1365,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 +1397,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 +1435,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 +1522,7 @@ function Get-Token { write-host "[*] Calling the token endpoint" - + #Define headers (emulate Azure CLI) $Headers = @{ "User-Agent" = $UserAgent @@ -1588,7 +1588,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 +1597,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 +1616,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...." @@ -1650,7 +1650,7 @@ function Get-Token { } else { Write-Host "[i] Expires at: $($tokens.expiration_time)" } - + $AuthError = $false if (-Not $AuthError) { @@ -1658,7 +1658,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 +1684,7 @@ function Get-Token { Invoke-Reporting -ErrorDetails $ErrorDetails -OutputFile "Auth_report_$($ReportName)_error.csv" } return - } + } } @@ -1695,9 +1695,9 @@ function Show-EntraTokenAidHelp { $banner = @' ______ __ ______ __ ___ _ __ / ____/___ / /__________ /_ __/___ / /_____ ____ / | (_)___/ / - / __/ / __ \/ __/ ___/ __ `// / / __ \/ //_/ _ \/ __ \/ /| | / / __ / - / /___/ / / / /_/ / / /_/ // / / /_/ / ,< / __/ / / / ___ |/ / /_/ / -/_____/_/ /_/\__/_/ \__,_//_/ \____/_/|_|\___/_/ /_/_/ |_/_/\__,_/ + / __/ / __ \/ __/ ___/ __ `// / / __ \/ //_/ _ \/ __ \/ /| | / / __ / + / /___/ / / / /_/ / / /_/ // / / /_/ / ,< / __/ / / / ___ |/ / /_/ / +/_____/_/ /_/\__/_/ \__,_//_/ \____/_/|_|\___/_/ /_/_/ |_/_/\__,_/ '@ # Header diff --git a/modules/Send-ApiRequest.psm1 b/modules/Send-ApiRequest.psm1 index 6354eab..5ac430e 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 #> diff --git a/modules/Send-GraphBatchRequest.psm1 b/modules/Send-GraphBatchRequest.psm1 index 67c03df..020f09a 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])") } @@ -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..0784e64 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.). @@ -47,7 +47,7 @@ function Invoke-CheckAppRegistrations { } else { $Department = $User.Department } - [PSCustomObject]@{ + [PSCustomObject]@{ Type = "User" DisplayName = $User.DisplayName UPN= $User.UserPrincipalName @@ -65,7 +65,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 +83,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 +103,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 +122,7 @@ function Invoke-CheckAppRegistrations { $Expired = $False } } - [PSCustomObject]@{ + [PSCustomObject]@{ Type = "Cert" DisplayName = $Object.DisplayName EndDateTime = $Object.EndDateTime @@ -174,7 +174,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 +241,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 +306,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 +417,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 +493,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 +549,7 @@ function Invoke-CheckAppRegistrations { } else { @() } - + $CloudAppAdminCurrentAppDetails = foreach ($Object in $CloudAppAdminCurrentApp) { # Get the object details $ObjectDetails = GetObjectInfo $Object.PrincipalId @@ -559,7 +559,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]) @@ -683,9 +683,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 +719,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 +737,7 @@ function Invoke-CheckAppRegistrations { #Define stringbuilder to avoid performance impact $DetailTxtBuilder = [System.Text.StringBuilder]::new() - + foreach ($item in $details) { $ReportingAppRegInfo = @() $ReportingCredentials = @() @@ -749,7 +749,7 @@ function Invoke-CheckAppRegistrations { $ScopedAdminUser = @() $ScopedAdminGroup = @() $ScopedAdminSP = @() - + [void]$DetailTxtBuilder.AppendLine("############################################################################################################################################") @@ -783,7 +783,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 +836,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 +866,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) @@ -891,7 +891,7 @@ function Invoke-CheckAppRegistrations { } ############### Scoped Admins - + #Wrap to Array and merge $CloudAppAdminCurrentAppDetails = @($item.CloudAppAdminCurrentAppDetails) $AppAdminCurrentAppDetails = @($item.AppAdminCurrentAppDetails) @@ -908,7 +908,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 +943,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 +975,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 +1022,7 @@ function Invoke-CheckAppRegistrations { [void]$DetailTxtBuilder.AppendLine(($ReportingAppRoles | format-table | Out-String)) } - + $ObjectDetails=[pscustomobject]@{ "Object Name" = $item.DisplayName "Object ID" = $item.Id @@ -1037,7 +1037,7 @@ function Invoke-CheckAppRegistrations { "Admins (Groups)" = $ScopedAdminGroup "Admins (ServicePrincipals)" = $ScopedAdminSP } - + [void]$AllObjectDetailsHTML.Add($ObjectDetails) } @@ -1066,7 +1066,7 @@ $ObjectsDetailsHEAD = @' '@ $AllObjectDetailsHTML = $ObjectsDetailsHEAD + "`n" + $AllObjectDetailsHTML + "`n" + '' - + #Define header $headerTXT = "************************************************************************************************************************ $Title Enumeration @@ -1076,13 +1076,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 = " @@ -1140,7 +1140,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..53411ff 100644 --- a/modules/check_CAPs.psm1 +++ b/modules/check_CAPs.psm1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Enumerate CAPs @@ -32,7 +32,7 @@ function Invoke-CheckCaps { return $false } } - + # Function to check if an object is empty, considering nested properties function Is-Empty { param ([Object]$Obj) @@ -121,7 +121,7 @@ function Invoke-CheckCaps { } elseif ($Report -eq "TXT") { return $ResolvedGUID } - + } if ($AllGroupsDetails.ContainsKey($Guid)) { $ResolvedGUID = $($AllGroupsDetails[$Guid].DisplayName) @@ -134,12 +134,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 +149,7 @@ function Invoke-CheckCaps { return $ResolvedGUID } } - if ($RoleTemplatesHT.ContainsKey($Guid)) { + if ($RoleTemplatesHT.ContainsKey($Guid)) { $ResolvedGUID = $($RoleTemplatesHT[$Guid]) return $ResolvedGUID } @@ -279,7 +279,7 @@ function Invoke-CheckCaps { $TargetedLocations = $TargetedLocations -join ", " } } - + "#microsoft.graph.ipNamedLocation" { $NamedLocationType = "IP ranges" $TargetedLocations = $location.ipRanges.cidrAddress @@ -300,11 +300,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 +322,13 @@ function Invoke-CheckCaps { } else { "" } - + $ExcludedCAPsTextLinks = if ($MatchingCAPsExcluded) { ( $MatchingCAPsExcluded | ForEach-Object { "$($_.DisplayName)" } ) -join ", " } else { "" } - + [pscustomobject]@{ "Id" = $location.Id "Name" = $location.DisplayName @@ -401,13 +401,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 +477,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 +536,7 @@ function Invoke-CheckCaps { $ExcludedExternalUsersCount = ($ExcludedExternalUsers -split ',').Count } - + if ($policy.Conditions.ClientAppTypes -contains "all") { $ClientAppTypesCount = 0 } else { @@ -566,11 +566,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 +581,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 +597,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 +626,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 +654,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 +690,7 @@ function Invoke-CheckCaps { $PolicyDeviceCodeFlow = $true $DeviceCodeFlowWarnings = 0 $ErrorMessages = @() - + if ($policy.State -ne "enabled") { $ErrorMessages += "is not enabled" $DeviceCodeFlowWarnings++ @@ -709,7 +709,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 +739,7 @@ function Invoke-CheckCaps { $PolicyLegacyAuth = $true $LegacyAuthWarnings = 0 $ErrorMessages = @() - + if ($policy.State -ne "enabled") { $ErrorMessages += "is not enabled" $LegacyAuthWarnings++ @@ -758,7 +758,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 +773,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 +789,7 @@ function Invoke-CheckCaps { $PolicyRiskySignIn = $true $SignInRiskWarnings = 0 $ErrorMessages = @() - + if ($policy.State -ne "enabled") { $ErrorMessages += "is not enabled" $SignInRiskWarnings++ @@ -804,7 +804,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 +819,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 +835,7 @@ function Invoke-CheckCaps { $PolicyUserRisk = $true $UserRiskWarnings = 0 $ErrorMessages = @() - + if ($policy.State -ne "enabled") { $ErrorMessages += "is not enabled" $UserRiskWarnings++ @@ -850,7 +850,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 +865,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 +898,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 +929,7 @@ function Invoke-CheckCaps { $PolicyRegSecInfo = $true $RegisterSecInfosWarnings = 0 $ErrorMessages = @() - + if ($policy.State -ne "enabled") { $ErrorMessages += "is not enabled" $RegisterSecInfosWarnings++ @@ -948,13 +948,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 +964,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 +989,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 +1011,7 @@ function Invoke-CheckCaps { $PolicyMfaUser = $true $UserMfaWarnings = 0 $ErrorMessages = @() - + if ($policy.State -ne "enabled") { $ErrorMessages += "is not enabled" $UserMfaWarnings++ @@ -1040,7 +1040,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 +1059,7 @@ function Invoke-CheckCaps { $PolicyAuthStrength = $true $AuthStrengthWarnings = 0 $ErrorMessages = @() - + if ($policy.State -ne "enabled") { $ErrorMessages += "is not enabled" $AuthStrengthWarnings++ @@ -1078,13 +1078,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 +1096,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 +1118,7 @@ function Invoke-CheckCaps { $AuthStrengthId = [string]$policy.GrantControls.AuthenticationStrength.Id } } - + $ConditionalAccessPolicies.Add([PSCustomObject]@{ Id = $policy.Id @@ -1205,7 +1205,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 +1236,7 @@ $MissingPolicies $HtmlGrantControls = @() $MissingRoles = @() $ScopedRoles = @() - + [void]$DetailTxtBuilder.AppendLine("############################################################################################################################################") $ReportingCapInfo = [pscustomobject]@{ @@ -1244,7 +1244,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 +1261,7 @@ $MissingPolicies if ($matchingWarnings -ne "") { $ReportingCapInfo | Add-Member -NotePropertyName Warnings -NotePropertyValue $matchingWarnings } - + [void]$DetailTxtBuilder.AppendLine(($ReportingCapInfo | Format-List | Out-String)) @@ -1270,7 +1270,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 +1289,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 +1315,8 @@ $MissingPolicies "AssignmentsScoped" = $($object.AssignmentsScopedLink) } } - } - + } + # Convert the raw CAP JSON to YAML, enriching it with HTTP links. if ($null -ne $item.Conditions) { @@ -1368,9 +1368,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 +1380,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 +1437,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 +1464,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 +1473,7 @@ $headerHtml = @" $EnabledCount ++ } } - $GlobalAuditSummary.ConditionalAccess.Enabled = $EnabledCount + $GlobalAuditSummary.ConditionalAccess.Enabled = $EnabledCount #Convert to Hashtable for faster searches $AllCapsHT = @{} @@ -1481,6 +1481,6 @@ $headerHtml = @" $AllCapsHT[$item.Id] = $item } Return $AllCapsHT - + } diff --git a/modules/check_EnterpriseApps.psm1 b/modules/check_EnterpriseApps.psm1 index 17c278b..bf144dd 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]@{ @@ -384,7 +384,7 @@ function Invoke-CheckEnterpriseApps { $OwnerUserDetails = @() $OwnerSPDetails = @() $AppRegObjectId = "" - + # Display status based on the objects numbers (slightly improves performance) if ($ProgressCounter % $StatusUpdateInterval -eq 0 -or $ProgressCounter -eq $EnterpriseAppsCount) { @@ -488,19 +488,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 +566,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 +608,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 +632,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 +673,8 @@ function Invoke-CheckEnterpriseApps { } } } - - $OwnedGroups = foreach ($Group in $OwnedGroups) { + + $OwnedGroups = foreach ($Group in $OwnedGroups) { Get-GroupDetails -Group $Group -AllGroupsDetails $AllGroupsDetails } @@ -718,7 +718,7 @@ function Invoke-CheckEnterpriseApps { $OwnedApplicationsCount = $OwnedApplications.count $OwnedSPCount = $OwnedSP.count - + #Check if sp has configured credentials $AppCredentialsSecrets = foreach ($creds in $item.PasswordCredentials) { @@ -748,8 +748,8 @@ function Invoke-CheckEnterpriseApps { } ########################################## SECTION: RISK RATING AND WARNINGS ########################################## - - + + # Check if it the Entra Connect Sync App if ($item.DisplayName -match "ConnectSyncProvisioning_") { $EntraConnectApp = $true @@ -771,7 +771,7 @@ function Invoke-CheckEnterpriseApps { #If not set corresponding SP object ID $AppRegObjectId = $AppRegistrations[$($item.AppId)].id - + } else { $ForeignTenant = $true } @@ -824,7 +824,7 @@ function Invoke-CheckEnterpriseApps { } } } - + $OwnersCount = $OwnerUserDetails.count + $OwnerSPDetails.count #Check owners of the SP. if ($OwnersCount -ge 1) { @@ -836,7 +836,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 +846,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 +945,7 @@ function Invoke-CheckEnterpriseApps { $Warnings += $EntraRolesProcessedDetails.Warning $ImpactScore += $EntraRolesProcessedDetails.ImpactScore } - + #If SP owns groups if (($OwnedGroups | Measure-Object).count -ge 1) { @@ -1041,7 +1041,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 +1107,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 +1135,7 @@ function Invoke-CheckEnterpriseApps { '' } - # if + # if if ($AppsignInData.lastSignInDays) { $LastSignInDays = $AppsignInData.lastSignInDays } else { @@ -1145,7 +1145,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 +1194,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 +1216,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 +1224,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 +1256,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 +1289,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 +1350,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 +1362,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 +1378,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 +1392,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 +1416,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 +1445,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 +1470,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 +1490,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 +1523,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 +1540,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 +1569,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 +1583,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 +1600,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 +1614,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 +1630,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 +1651,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 +1668,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 +1688,7 @@ function Invoke-CheckEnterpriseApps { $PrincipalUpnLink = $object.Principal } - [pscustomobject]@{ + [pscustomobject]@{ "APIName" = $object.APIName "Permission" = $object.Scope "Categorization" = $object.ApiPermissionCategorization @@ -1704,7 +1704,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 +1733,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 +1818,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 +1855,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..a18630c 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 @@ -144,36 +144,36 @@ function Invoke-CheckGroups { param ( [Parameter(Mandatory = $true)] [object]$StartGroup, - + [Parameter(Mandatory = $true)] [hashtable]$GroupLookup, - + [Parameter(Mandatory = $true)] [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 +183,7 @@ function Invoke-CheckGroups { } } } - + $NestedGroupCache[$StartGroup.Id] = $allNestedGroups return $allNestedGroups } @@ -211,7 +211,7 @@ function Invoke-CheckGroups { $GroupImpactScore = @{ "M365Group" = 1 - "HiddenGAL" = 1 + "HiddenGAL" = 1 "Distribution" = 0.5 "SecurityEnabled" = 2 "AzureRole" = 100 @@ -233,7 +233,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 +245,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 +268,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 +283,7 @@ function Invoke-CheckGroups { AssignmentType = "Eligible" } } - + # Add the object to the array for that groupId $PimForGroupsEligibleOwnersHT[$assignment.groupId] += $OwnerInfo } @@ -298,7 +298,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 +314,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 +337,10 @@ function Invoke-CheckGroups { AssignmentType = "Eligible" } } - + # Add the object to the array for that groupId $PimForGroupsEligibleMembersHT[$assignment.groupId] += $MemberInfo - + } } @@ -378,7 +378,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 +392,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 +434,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 +450,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} @@ -541,9 +541,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 +560,7 @@ function Invoke-CheckGroups { } } } - } + } $GroupNestedInRaw = @{} foreach ($group in $AllGroups) { @@ -583,7 +583,7 @@ function Invoke-CheckGroups { foreach ($app in $RawResponse) { $AllSPBasicHT[$app.id] = $app } - + #Remove Variables remove-variable parents -ErrorAction SilentlyContinue @@ -591,7 +591,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 +602,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 +642,7 @@ function Invoke-CheckGroups { } } - + #Check if group has an app role if ($AppRoleAssignmentsRaw.ContainsKey($group.Id)) { foreach ($AppRole in $AppRoleAssignmentsRaw[$group.Id]) { @@ -653,7 +653,7 @@ function Invoke-CheckGroups { }) } } - + # Initialize ArrayLists $memberUser = [System.Collections.Generic.List[psobject]]::new() $memberGroup = [System.Collections.Generic.List[psobject]]::new() @@ -666,7 +666,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 +677,7 @@ function Invoke-CheckGroups { } ) } - + '#microsoft.graph.group' { [void]$memberGroup.Add( @@ -687,7 +687,7 @@ function Invoke-CheckGroups { } ) } - + '#microsoft.graph.servicePrincipal' { [void]$memberSP.Add( [PSCustomObject]@{ @@ -695,7 +695,7 @@ function Invoke-CheckGroups { } ) } - + '#microsoft.graph.device' { [void]$memberDevices.Add( [PSCustomObject]@{ @@ -711,7 +711,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 +722,7 @@ function Invoke-CheckGroups { } ) } - + '#microsoft.graph.servicePrincipal' { [void]$ownersp.Add( [PSCustomObject]@{ @@ -730,7 +730,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 +747,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) @@ -776,7 +776,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 @@ -802,12 +802,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 +818,7 @@ function Invoke-CheckGroups { } else { $false } - + #Count the owners to show in table $ownersynced = 0 @@ -875,7 +875,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 +895,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 +1049,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 +1119,7 @@ function Invoke-CheckGroups { "TargetGroups" = $ownerGroup.Id }) } - + } #Check if M365 group is public @@ -1132,9 +1132,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 +1145,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 +1202,8 @@ function Invoke-CheckGroups { if ($group.SecurityEnabled) { $ImpactScore += $GroupImpactScore["SecurityEnabled"] } - - + + #Format warning messages $Warnings = ($Warnings -join ' / ') @@ -1236,8 +1236,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 +1347,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 +1367,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 +1384,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 +1392,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 +1400,7 @@ function Invoke-CheckGroups { } elseif ($group.Warnings -notmatch [regex]::Escape($message)) { $group.Warnings += " / $message" } - + $group.InheritedHighValue += 1 } } @@ -1413,7 +1413,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 +1432,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 +1489,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 +1524,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 +1598,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 +1618,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 +1637,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 +1646,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 +1656,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 +1665,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 +1690,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 +1721,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 +1752,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 +1764,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 +1781,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 +1821,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 +1858,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 +1877,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 +1913,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 +1939,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 +1969,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 +1983,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 +1994,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 +2014,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr IsAssignableToRole = $obj.IsAssignableToRole }) } - + if ($ExceedsLimit) { [void]$NestedGroups.Add([pscustomobject]@{ AssignmentType = "-" @@ -2024,8 +2024,8 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr }) } } - - + + ############### Nested Users @@ -2035,10 +2035,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 +2048,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 +2078,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 +2097,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 +2113,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr $DisplayNameLink = "$($DisplayName)" $org = "-" } - + $rawObj = [pscustomobject]@{ DisplayName = $DisplayName DisplayNameLink = $DisplayNameLink @@ -2122,10 +2122,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 +2133,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 +2164,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 +2224,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 +2237,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 +2260,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 +2274,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr CAPs = $obj.CAPs }) } - + if ($ExceedsLimit) { [void]$NestedInGroups.Add([pscustomobject]@{ AssignmentType = "-" @@ -2307,7 +2307,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 +2320,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 +2344,7 @@ $tableOutput | Format-table DisplayName,type,SecurityEnabled,RoleAssignable,OnPr }) } } - + $ObjectDetails = [pscustomobject]@{ "Object Name" = $item.DisplayName "Object ID" = $item.Id @@ -2361,12 +2361,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 +2413,7 @@ $AppendixTitle = " Appendix: Dynamic Groups ############################################################################################################################################### " - + $PmGeneratingDetails.Stop() $PmWritingReports = [System.Diagnostics.Stopwatch]::StartNew() @@ -2474,7 +2474,7 @@ $headerHtml = @" } if ($group.PIM -eq $true) { $PimOnboarded++ - } + } } # Store in global var @@ -2526,7 +2526,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..aa712b0 100644 --- a/modules/check_PIM.psm1 +++ b/modules/check_PIM.psm1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Enumerates PIM role configuration. #> @@ -77,7 +77,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 +87,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 +107,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 +140,7 @@ function Invoke-CheckPIM { EligibleAssignments = $eligibleAssignments.Count } } - + # Build a lookup for PolicyId -> Rules $PolicyRulesMap = @{} @@ -276,7 +276,7 @@ function Invoke-CheckPIM { $parsedAdminAssignmentDurationValue = "-" $parsedAdminAssignmentDurationUnit = "" } - + # Extract Enablement_Admin_Assignment $adminAssignmentEnabledRules = @() @@ -303,13 +303,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 +393,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 +449,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") @@ -610,7 +610,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 +648,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 +664,7 @@ function Invoke-CheckPIM { [void]$DetailTxtBuilder.AppendLine("Activation Settings") [void]$DetailTxtBuilder.AppendLine("================================================================================================") [void]$DetailTxtBuilder.AppendLine(($ActivationSettings | format-table | Out-String)) - + ############## Approvers @@ -678,7 +678,7 @@ function Invoke-CheckPIM { default { $($object.Description) } } - [pscustomobject]@{ + [pscustomobject]@{ "Type" = $object.Type "DisplayNameLink" = $DisplayNameLink "DisplayName" = $object.Description @@ -689,7 +689,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 +713,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 +738,7 @@ function Invoke-CheckPIM { if ($item.EligibleExpiration) { $MaxEligibleAssignment = "$($item.EligibleExpirationTime) $($item.EligibleExpirationUnit)" } else { - $MaxEligibleAssignment = "-" + $MaxEligibleAssignment = "-" } if ($item.ActiveExpiration) { @@ -748,7 +748,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 +763,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 +774,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 +785,7 @@ function Invoke-CheckPIM { "Assignment Settings" = $AssignmentSettings "Notification Settings" = $NotificationSettings } - + [void]$AllObjectDetailsHTML.Add($ObjectDetails) } @@ -827,7 +827,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..6b42369 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. #> @@ -39,7 +39,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 +52,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 +78,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 +87,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 +100,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 +118,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 +266,7 @@ function Invoke-CheckRoles { #Unknown Object if ($normalizedType -eq "unknown") { - $object = [PSCustomObject]@{ + $object = [PSCustomObject]@{ DisplayName = $ObjectID DisplayNameLink = $ObjectID Type = "Unknown Object" @@ -320,7 +320,7 @@ function Invoke-CheckRoles { 3 {$RoleTier = "Tier-3"; break} "?" {$RoleTier = "Uncategorized"} } - [pscustomobject]@{ + [pscustomobject]@{ "Role" = $($item.DisplayName) "PrincipalId" = $($item.PrincipalId) "PrincipalDisplayName" = $($PrincipalDetails.DisplayName) @@ -538,7 +538,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 +593,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 +606,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..59c902f 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:$false

    Refer 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:

    1. Select Enterprise Applications
    2. Select Consent and permissions
    3. Select Do not allow user consent

    References:

    ' - + Confidence = "Requires Verification" } } @@ -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..52ecd23 100644 --- a/modules/check_Users.psm1 +++ b/modules/check_Users.psm1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Enumerates and analyzes all users in the current tenant, including access, ownerships, roles, and risk posture. @@ -64,7 +64,7 @@ function Invoke-CheckUsers { "DirectAppRoleSensitive" = 50 "SpOwnAppLock" = 20 } - + $UserLikelihood = @{ "Base" = 5 "SyncedFromOnPrem" = 3 @@ -123,24 +123,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 +181,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 +192,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 +232,7 @@ function Invoke-CheckUsers { } } } - + # Count transitive memberships $TotalTransitiveMemberRelations = 0 @@ -283,7 +283,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 +307,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 +318,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 +358,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 +412,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 +420,7 @@ function Invoke-CheckUsers { } ) } - + '#microsoft.graph.application' { [void]$UserOwnedAppRegs.Add( [PSCustomObject]@{ @@ -454,7 +454,7 @@ function Invoke-CheckUsers { } ) } - + '#microsoft.graph.group' { [void]$UserOwnedGroups.Add( [PSCustomObject]@{ @@ -463,7 +463,7 @@ function Invoke-CheckUsers { } ) } - + default { Write-Log -Level Debug -Message "Unknown owned object type: $($OwnedObject.'@odata.type') for user $($user.Id)" } @@ -481,7 +481,7 @@ function Invoke-CheckUsers { } ) } - } + } #Get users registered devices $DeviceRegistered = [System.Collections.Generic.List[object]]::new() @@ -493,7 +493,7 @@ function Invoke-CheckUsers { } ) } - } + } if ($TenantPimForGroupsAssignments) { if ($UserGroupMapping.ContainsKey($item.Id)) { @@ -527,7 +527,7 @@ function Invoke-CheckUsers { } if (@($MatchingEnterpriseApp).count -ge 1) { - [PSCustomObject]@{ + [PSCustomObject]@{ Id = $MatchingEnterpriseApp.Id DisplayName = $MatchingEnterpriseApp.DisplayName AppLock = $AppLock @@ -551,7 +551,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 +566,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 +590,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 +603,7 @@ function Invoke-CheckUsers { Impact = $MatchingGroup.Impact }) } - } + } #Sort by impact $GroupMemberDetails = $GroupMemberDetails | Sort-Object -Property Impact -Descending @@ -611,8 +611,8 @@ function Invoke-CheckUsers { $UserDirectAppRoles = $GLOBALUserAppRoles[$item.Id] if ($null -eq $UserDirectAppRoles) { $UserDirectAppRolesCount = 0 - } else { - $UserDirectAppRolesCount = @($UserDirectAppRoles).Count + } else { + $UserDirectAppRolesCount = @($UserDirectAppRoles).Count } @@ -637,13 +637,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 +714,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 +766,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 +817,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 +856,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 +955,7 @@ function Invoke-CheckUsers { } else { '' } - + #Combine Direct assigned Entra roles + roles trough group $TotalEntraRoles = $EntraRolesTroughGroupOwnership + $EntraRolesTroughGroupMembership + @($UserEntraRoles).count @@ -964,14 +964,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 +1022,9 @@ function Invoke-CheckUsers { Likelihood = [math]::Round($Likelihood,1) Risk = $Risk Warnings = $Warnings - } + } + - [void]$AllUsersDetails.Add($UserDetails) @@ -1038,7 +1038,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 +1072,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 +1165,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 +1203,7 @@ function Invoke-CheckUsers { $maxWarningsLength = $warnings.Length } - [pscustomobject]@{ + [pscustomobject]@{ "AssignmentType" = $object.AssignmentType "DisplayName" = $displayName "DisplayNameLink" = "$($displayName)" @@ -1226,8 +1226,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 +1249,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 +1277,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 +1327,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 +1361,7 @@ function Invoke-CheckUsers { $OsLength = $Os.Length } - [pscustomobject]@{ + [pscustomobject]@{ "Displayname" = $DiplayName "Type" = $DeviceDetails.trustType "OS" = $Os @@ -1374,15 +1374,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 +1399,7 @@ function Invoke-CheckUsers { $maxAppRoleNameLength = 0 $maxDescriptionLength = 0 $maxAppNameLength = 0 - + $ReportingAppRoles = foreach ($object in $($item.AppRolesDetails)) { $AppRoleName = $object.AppRoleDisplayName @@ -1414,7 +1414,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 +1422,7 @@ function Invoke-CheckUsers { "App" = $AppName } } - + $formattedText = Format-ReportSection -Title "Directly Assigned AppRoles" ` -Objects $ReportingAppRoles ` -Properties @("AppRoleName", "Enabled", "Description", "App") ` @@ -1453,7 +1453,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 +1488,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 +1512,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 +1543,7 @@ function Invoke-CheckUsers { "Member of Groups (Transitive)" = $ReportingMemberGroup "Azure IAM assignments" = $ReportingAzureRoles } - + [void]$AllObjectDetailsHTML.Add($ObjectDetails) } @@ -1603,7 +1603,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 @"
    @@ -484,7 +484,7 @@ document.addEventListener('DOMContentLoaded', function () { 'Group': $($GlobalAuditSummary.AzureRoleAssignments.PrincipalType.Group), 'ServicePrincipal': $($GlobalAuditSummary.AzureRoleAssignments.PrincipalType.SP), 'Unknown': $($GlobalAuditSummary.AzureRoleAssignments.PrincipalType.Unknown) - } + } }; // === 2. Shared chart config === @@ -578,7 +578,7 @@ document.addEventListener('DOMContentLoaded', function () { }], }; } - + // ============ Groups ============ if (datasetKey === 'groups_general') { @@ -951,7 +951,7 @@ document.addEventListener('DOMContentLoaded', function () { { id: 'user_chart4', title: 'Cloud-Only vs Synced', type: 'bar', dataset: 'users_onprem', showLegend: false }, { id: 'user_chart5', title: 'Active vs Inactive', type: 'bar', dataset: 'users_inactive', showLegend: false }, { id: 'user_chart6', title: 'Last Successful Sign-In', type: 'bar', dataset: 'users_lastsignin', indexAxis: 'y', showLegend: false }, - + // ============ Groups ============ { id: 'group_chart1', title: 'Security vs M365', type: 'doughnut', dataset: 'groups_general' }, { id: 'group_chart2', title: 'Cloud-Only vs Synced', type: 'bar', dataset: 'groups_onprem', showLegend: false }, @@ -1246,7 +1246,7 @@ Enumeration Results: # Build header section $headerHTML = "
    Loading data...
    $generalSectionHtml" - + #Write HTML $PostContentCombined = $Chartsection + "`n" + $GLOBALJavaScript $CssCombined = $GLOBALcss + $CustomCss + $global:GLOBALReportManifestScript diff --git a/modules/shared_Functions.psm1 b/modules/shared_Functions.psm1 index 0963cdb..95525f7 100644 --- a/modules/shared_Functions.psm1 +++ b/modules/shared_Functions.psm1 @@ -204,8 +204,8 @@ $global:GLOBALJavaScript_Table = @' "Enterprise Apps": [ { label: "Foreign Apps: Privileged", - filters: { - Foreign: "=True", + filters: { + Foreign: "=True", ApiDangerous: "or_>0", ApiHigh: "or_>0", ApiMedium: "or_>0", @@ -222,8 +222,8 @@ $global:GLOBALJavaScript_Table = @' }, { label: "Foreign Apps: Extensive API Privs (Application)", - filters: { - Foreign: "=True", + filters: { + Foreign: "=True", ApiDangerous: "or_>0", ApiHigh: "or_>0", ApiMedium: "or_>0" @@ -232,8 +232,8 @@ $global:GLOBALJavaScript_Table = @' }, { label: "Foreign Apps: Extensive API Privs (Delegated)", - filters: { - Foreign: "=True", + filters: { + Foreign: "=True", ApiDelegatedDangerous: "or_>0", ApiDelegatedHigh: "or_>0", ApiDelegatedMedium: "or_>0" @@ -242,8 +242,8 @@ $global:GLOBALJavaScript_Table = @' }, { label: "Foreign Apps: With Roles", - filters: { - Foreign: "=True", + filters: { + Foreign: "=True", EntraRoles: "or_>0", AzureRoles: "or_>0" }, @@ -251,8 +251,8 @@ $global:GLOBALJavaScript_Table = @' }, { label: "Internal Apps: Privileged", - filters: { - Foreign: "=False", + filters: { + Foreign: "=False", ApiDangerous: "or_>0", ApiHigh: "or_>0", ApiDelegatedDangerous: "or_>0", @@ -291,7 +291,7 @@ $global:GLOBALJavaScript_Table = @' }, { label: "Entra Connect Application", - filters: { + filters: { DisplayName: "^ConnectSyncProvisioning_" }, columns: ["DisplayName", "Enabled", "Inactive", "Owners", "Credentials", "GrpMem", "GrpOwn", "AppOwn", "SpOwn", "EntraRoles", "AzureRoles", "ApiDangerous", "ApiHigh", "ApiMedium", "ApiLow", "ApiMisc", "ApiDelegated", "Impact", "Likelihood", "Risk", "Warnings"] @@ -368,7 +368,7 @@ $global:GLOBALJavaScript_Table = @' }, { label: "Entra Connect Application", - filters: { + filters: { DisplayName: "^ConnectSyncProvisioning_" } } @@ -416,20 +416,20 @@ $global:GLOBALJavaScript_Table = @' filters: { AppTypes: "exchangeActiveSync||other" } - }, + }, { label: "Device Code Flow Policies", filters: { AuthFlow: "deviceCodeFlow" } - }, + }, { label: "Network Location Policies", filters: { IncNw: "or_!=0", ExcNw: "or_!=0" } - }, + }, { label: "Session Control Policies", filters: { @@ -624,22 +624,22 @@ $global:GLOBALJavaScript_Table = @' "AssignmentType": "Activated eligible assignments also appear as active", "Conditions": "Has additional conditions" }; - - (function () { + + (function () { const manifestEl = document.getElementById("report-manifest"); const manifest = manifestEl && manifestEl.textContent ? JSON.parse(manifestEl.textContent) : null; - window.__reportManifest = manifest; + window.__reportManifest = manifest; const mainTableDataEl = document.getElementById("mainTableData"); if (!mainTableDataEl) { return; } - + const container = document.getElementById("mainTableContainer"); if (!container) { return; } - + let data = JSON.parse(document.getElementById("mainTableData").textContent); if (!Array.isArray(data)) { data = [data]; // wrap single object into an array @@ -1006,7 +1006,7 @@ $global:GLOBALJavaScript_Table = @' function getVisibleColumns() { return columns.filter(col => !hiddenColumns.has(col)); } - + // Renders main table function renderTable() { let start = (currentPage - 1) * rowsPerPage; @@ -1117,10 +1117,10 @@ $global:GLOBALJavaScript_Table = @' newInput.setSelectionRange(caretPos, caretPos); } } - } + } } - + //Pagination for the main table function renderPagination() { const totalPages = Math.max(1, Math.ceil(filteredData.length / rowsPerPage)); @@ -1137,7 +1137,7 @@ $global:GLOBALJavaScript_Table = @' pagination.innerHTML = html; } - + // Displays how many entries are shown (e.g., "Showing 1-10 of 50 entries") function renderInfo(start, end) { const shownStart = filteredData.length === 0 ? 0 : start + 1; @@ -1149,7 +1149,7 @@ $global:GLOBALJavaScript_Table = @' currentPage = page; renderTable(); }; - + //MainTable sort function (special handling of cells containing links) function sortData() { const { column, asc } = currentSort; @@ -1282,7 +1282,7 @@ $global:GLOBALJavaScript_Table = @' return rawStr.includes(lowerInput); } - + // Applies per-column filters function filterData() { const groups = {}; // { groupName: [ { col, input } ] } @@ -1478,7 +1478,7 @@ $global:GLOBALJavaScript_Table = @' currentSort.column = "Risk"; currentSort.asc = false; } - + // Init createColumnSelector(); createToolbar(); @@ -1679,7 +1679,7 @@ $global:GLOBALJavaScript_Table = @' }, 100); } } - + //YAML rendering CAP function renderPreBlock(title, lines) { const section = document.createElement('div'); @@ -2317,7 +2317,7 @@ $global:GLOBALJavaScript_Nav = @'
  • Risk scores are not directly comparable between object types or reports.
  • It is not intended to replace a full risk assessment.
  • - + \u{1F4D6} More information in the GitHub README
    @@ -2575,7 +2575,7 @@ $global:GLOBALCss = @" padding: 6px; max-width: 100%; } - + .property-table th { font-size: 12px; padding-left: 8px; @@ -3701,7 +3701,7 @@ function EnsureAuthMsGraph { if (AuthCheckMSGraph) { write-host "[+] MS Graph session OK" $result = $true - + } else { if (AuthenticationMSGraph) { write-host "[+] MS Graph successfully authenticated" @@ -3710,7 +3710,7 @@ function EnsureAuthMsGraph { if (-not $GLOBALAuthParameters['Tenant']) {write-host "[i] Maybe try to specify the tenant: -Tenant"} Write-host "[!] Aborting" $result = $false - + } } Return $result @@ -3777,7 +3777,7 @@ function Get-RegisterAuthMethodsUsers { write-host "[!] Auth error: $($_.Exception.Message -split '\n'). Can't retrieve users auth methods." } } - + #Convert to HT $UserAuthMethodsTable = @{} foreach ($method in $RegisteredAuthMethods ) { @@ -3823,7 +3823,7 @@ function Get-Devices { } $DevicesRaw = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/devices" -QueryParameters $QueryParameters -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name) - + #Convert to HT $Devices = @{} foreach ($device in $DevicesRaw) { @@ -3831,7 +3831,7 @@ function Get-Devices { } Write-Log -Level Verbose -Message "Got $($Devices.Count) devices " - + return $Devices } @@ -3889,7 +3889,7 @@ function AuthenticationMSGraph { function AuthenticationAzurePSNative { - + #Get tokens for Azure ARM API invoke-EntraFalconAuth -Action Auth -Purpose Azure @GLOBALAuthMethods if (AuthCheckAzPSnative) { @@ -3964,7 +3964,7 @@ function Invoke-CheckTokenExpiration ($Object) { } elseif ($validForMinutes -le 30 -and $validForMinutes -ge 0) { write-host "[!] Access token will expire in $validForMinutes minutes" - $result = $false + $result = $false } else { write-host "[!] Access token has expired $([Math]::Abs($validForMinutes)) minutes ago" $result = $false @@ -4030,7 +4030,7 @@ $global:GLOBALAzureRoleRating = @{ "a6333a3e-0164-44c3-b281-7a577aff287f" = 1 #Windows Admin Center Administrator Login "3bc748fc-213d-45c1-8d91-9da5725539b9" = 1 #Container Registry Contributor and Data Access Configuration Administrator "00482a5a-887f-4fb3-b363-3b7fe8e74483" = 1 #Key Vault Administrator - "8b54135c-b56d-4d72-a534-26097cfdc8d8" = 1 #Key Vault Data Access Administrator + "8b54135c-b56d-4d72-a534-26097cfdc8d8" = 1 #Key Vault Data Access Administrator "b86a8fe4-44ce-4948-aee5-eccb2c155cd7" = 1 #Key Vault Secrets Officer "4633458b-17de-408a-b874-0445c86b69e6" = 1 #Key Vault Secrets User "3498e952-d568-435e-9b2c-8d77e338d7f7" = 1 #Azure Kubernetes Service RBAC Admin @@ -4089,7 +4089,7 @@ $global:GLOBALApiPermissionCategorizationList= @{ "62a82d76-70ea-41e2-9197-370581804d09" = "High" #Group.ReadWrite.All "dbaae8cf-10b5-4b86-a4a1-f871c94c6695" = "High" #GroupMember.ReadWrite.All "50483e42-d915-4231-9639-7fdb7fd190e5" = "High" #UserAuthenticationMethod.ReadWrite.All - "cc117bb9-00cf-4eb8-b580-ea2a878fe8f7" = "High" #User-PasswordProfile.ReadWrite.All + "cc117bb9-00cf-4eb8-b580-ea2a878fe8f7" = "High" #User-PasswordProfile.ReadWrite.All "a82116e5-55eb-4c41-a434-62fe8a61c773" = "High" #Sites.FullControl.All "678536fe-1083-478a-9c59-b99265e6b0d3" = "High" #Sites.FullControl.All SharePointAPI "9bff6588-13f2-4c48-bbf2-ddab62256b36" = "High" #Sites.Manage.All SharePointAPI @@ -4115,7 +4115,7 @@ $global:GLOBALApiPermissionCategorizationList= @{ "810c84a8-4a9e-49e6-bf7d-12d183f40d01" = "Medium" #Mail.Read "e2a3a72e-5f79-4c64-b1b1-878b674786c9" = "Medium" #Mail.ReadWrite "b633e1c5-b582-4048-a93e-9f11b44c7e96" = "Medium" #Mail.Send - "b8bb2037-6e08-44ac-a4ea-4674e010e2a4" = "Medium" #OnlineMeetings.ReadWrite.All + "b8bb2037-6e08-44ac-a4ea-4674e010e2a4" = "Medium" #OnlineMeetings.ReadWrite.All "de89b5e4-5b8f-48eb-8925-29c2b33bd8bd" = "Medium" #CustomSecAttributeAssignment.ReadWrite.All "89c8469c-83ad-45f7-8ff2-6e3d4285709e" = "Medium" #ServicePrincipalEndpoint.ReadWrite.All (Still an issue?) "4c390976-b2b7-42e0-9187-c6be3bead001" = "Low" #AgentIdentity.CreateAsManager @@ -4225,7 +4225,7 @@ function Invoke-EntraRoleProcessing { $Tier2Count = 0 $UnknownTierCount = 0 $roleSummary = "" - + foreach ($Role in $RoleDetails) { $RoleImpact = 0 switch ($Role.RoleTier) { @@ -4260,7 +4260,7 @@ function Invoke-EntraRoleProcessing { $EligibleImpactScore += $RoleImpact } } - + # Build role description parts $roleParts = @() if ($Tier0Count -ge 1) { $roleParts += "$Tier0Count (Tier0)" } @@ -4276,7 +4276,7 @@ function Invoke-EntraRoleProcessing { if ($roleParts.Count -gt 0) { $roleSummary = ($roleParts -join ", ") + " Entra "+$word+" assigned" } - + return [PSCustomObject]@{ ImpactScore = $ImpactScore EligibleImpactScore = $EligibleImpactScore @@ -4301,7 +4301,7 @@ function Invoke-AzureRoleProcessing { $Tier3Count = 0 $UnknownTierCount = 0 $roleSummary = "" - + foreach ($Role in $RoleDetails) { $RoleImpact = 0 switch ($Role.RoleTier) { @@ -4337,7 +4337,7 @@ function Invoke-AzureRoleProcessing { $EligibleImpactScore += $RoleImpact } } - + # Build role description parts $roleParts = @() if ($Tier0Count -ge 1) { $roleParts += "$Tier0Count (Tier0)" } @@ -4354,7 +4354,7 @@ function Invoke-AzureRoleProcessing { if ($roleParts.Count -gt 0) { $roleSummary = ($roleParts -join ", ") + " Azure "+$word+" assigned" } - + return [PSCustomObject]@{ ImpactScore = $ImpactScore EligibleImpactScore = $EligibleImpactScore @@ -4489,7 +4489,7 @@ function Get-AllAzureIAMAssignmentsNative { $roleName = $_.properties.RoleName $RoleType = $_.properties.type $objectId = ($_.id -split '/')[-1] - + # Store the values in the hashtable (ObjectId as the key, RoleName as the value) $roleHashTable[$objectId] = @{ RoleName = $roleName @@ -4507,7 +4507,7 @@ function Get-AllAzureIAMAssignmentsNative { $roleName = $_.properties.RoleName $RoleType = $_.properties.type $objectId = ($_.id -split '/')[-1] - + # Store the values in the hashtable (ObjectId as the key, RoleName as the value) $roleHashTable[$objectId] = @{ RoleName = $roleName @@ -4518,7 +4518,7 @@ function Get-AllAzureIAMAssignmentsNative { Write-Log -Level Debug -Message "Got $($roleHashTable.count) role definitions" - foreach ($subscription in $subscriptions) { + foreach ($subscription in $subscriptions) { #Active Roles $url = "https://management.azure.com/subscriptions/$($subscription.Id)/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01" $response = @(Send-ApiRequest -Method GET -Uri $url -AccessToken $GLOBALArmAccessToken.access_token -UserAgent $($GlobalAuditSummary.UserAgent.Name) -ErrorAction Stop) @@ -4548,7 +4548,7 @@ function Get-AllAzureIAMAssignmentsNative { RoleType = $RoleDetails.RoleType RoleTier = $RoleTier Scope = $resolvedScope - Conditions = $hasCondition + Conditions = $hasCondition PrincipalType = $_.properties.principalType AssignmentType = "Active" } @@ -4587,14 +4587,14 @@ function Get-AllAzureIAMAssignmentsNative { RoleType = $RoleDetails.RoleType RoleTier = $RoleTier Scope = $resolvedScope - Conditions = $hasCondition + Conditions = $hasCondition PrincipalType = $_.properties.principalType AssignmentType = "Eligible" } } Write-Log -Level Debug -Message "Got $($AssignmentsEligible.count) eligible role assignments" } - + $AllAssignments = @($AssignmentsActive) + @($AssignmentsEligible) foreach ($assignment in $AllAssignments) { @@ -4670,7 +4670,7 @@ function Get-PIMForGroupsAssignmentsDetails { foreach ($item in $TenantPimForGroupsAssignments) { $principalId = $item.principalId - + # Lookup displayname and object type for each object $ObjectInfo = Get-ObjectInfo $principalId @@ -4711,7 +4711,7 @@ function Get-AdministrativeUnitsWithMembers { $MembersUser = $Members | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.user'} | Select-Object id,@{n='Type';e={'User'}},displayName $MembersGroup = $Members | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group'} | Select-Object id,@{n='Type';e={'Group'}},displayName $MembersDevices = $Members | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.device'} | Select-Object id,@{n='Type';e={'Device'}},displayName - + # Create a custom object for the administrative unit with its members [pscustomobject]@{ AuId = $AdminUnit.Id @@ -4745,7 +4745,7 @@ function Get-ConditionalAccessPolicies { $includedGroups = $cap.Conditions.Users.IncludeGroups $ExcludeUsers = $cap.Conditions.Users.ExcludeUsers $IncludeUsers = $cap.Conditions.Users.IncludeUsers - [PSCustomObject]@{ + [PSCustomObject]@{ Id = $cap.Id CAPName = $cap.DisplayName ExcludedGroup = $excludedGroups @@ -4753,7 +4753,7 @@ function Get-ConditionalAccessPolicies { ExcludedUser = $ExcludeUsers IncludedUser = $IncludeUsers CAPStatus = $cap.State - } + } } $global:GLOBALPermissionForCaps = $true } else { @@ -4768,14 +4768,14 @@ function Get-ConditionalAccessPolicies { function Invoke-MsGraphAuthPIM { Invoke-EntraFalconAuth -Action Auth -Purpose PimforEntra @GLOBALAuthMethods - + #Abort if error if ($GLOBALPIMsGraphAccessToken) { if (AuthCheckMSGraph) { write-host "[+] MS Graph session OK" $result = $true $global:GLOBALGraphExtendedChecks = $true - + } else { Write-host "[!] Authentication with Managed Meeting Rooms client failed" $result = $false @@ -4793,7 +4793,7 @@ function Invoke-MsGraphAuthPIM { function Invoke-MsGraphRefreshPIM { invoke-EntraFalconAuth -Action Refresh -Purpose PimforEntra @GLOBALAuthMethods - + } @@ -4824,7 +4824,7 @@ function Get-EntraPIMRoleAssignments { '$expand' = "RoleDefinition" } $PimRoles = Send-GraphRequest -AccessToken $GLOBALPIMsGraphAccessToken.access_token -Method GET -Uri "/roleManagement/directory/roleEligibilitySchedules" -QueryParameters $QueryParameters -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name) -ErrorAction Stop - + } catch { if ($($_.Exception.Message) -match "Status: 400") { write-host "[!] HTTP 400 Error: Most likely due to missing Entra ID premium licence. Assuming no PIM for Entra roles is used." @@ -4945,7 +4945,7 @@ function Get-EntraRoleAssignments { ScopeResolved = ($ScopeResolved | select-object DisplayName,Type) } } - + Write-Host "[+] Retrieved $($TenantRoleAssignments.Count) role assignments" if ($TenantPimRoleAssignments.count -ge 1) { @@ -5068,7 +5068,7 @@ function Get-PimforGroupsAssignments { [CmdletBinding()] Param () $ResultAuthCheck = $true - + Write-Host "[*] Trigger interactive authentication for PIM for Groups assessment (skip with -SkipPimForGroups)" if (-not (invoke-EntraFalconAuth -Action Auth -Purpose PimforGroup @GLOBALAuthMethods)) { throw "[!] Authentication failed for PimforGroup" @@ -5118,17 +5118,17 @@ function Get-PimforGroupsAssignments { displayName = $_.displayName } } - + #Stored groups in global HT var to use in groups module $global:GLOBALPimForGroupsHT = @{} foreach ($item in $PimEnabledGroups) { $GLOBALPimForGroupsHT[$item.Id] = $item.displayName } - + $PimEnabledGroupsCount = ($PimEnabledGroups | Measure-Object).count if ($PimEnabledGroupsCount -ge 1) { Write-Host "[+] Got $PimEnabledGroupsCount PIM enabled groups" - + $Requests = @() $RequestID = 0 # Loop through each group and create a request entry @@ -5144,7 +5144,7 @@ function Get-PimforGroupsAssignments { # Send Batch request $PIMforGroupsAssignments = (Send-GraphBatchRequest -AccessToken $GLOBALPimForGroupAccessToken.access_token -Requests $Requests -BetaAPI -BatchDelay 0.5 -UserAgent $($GlobalAuditSummary.UserAgent.Name)).response.value Write-Host "[+] Got $($PIMforGroupsAssignments.Count) objects eligible for a PIM-enabled group" - + } else { Write-Host "[!] No PIM enabled groups found" $PIMforGroupsAssignments = "" @@ -5261,7 +5261,7 @@ function Get-ObjectInfo { } $EnterpriseApp = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/servicePrincipals/$ObjectID" -QueryParameters $QueryParameters -BetaAPI -Suppress404 -UserAgent $($GlobalAuditSummary.UserAgent.Name) if ($EnterpriseApp) { - $object = [PSCustomObject]@{ + $object = [PSCustomObject]@{ DisplayName = $EnterpriseApp.DisplayName Type = "Enterprise Application" } @@ -5277,7 +5277,7 @@ function Get-ObjectInfo { } $AppRegistration = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/applications/$ObjectID" -QueryParameters $QueryParameters -BetaAPI -Suppress404 -UserAgent $($GlobalAuditSummary.UserAgent.Name) if ($AppRegistration) { - $object = [PSCustomObject]@{ + $object = [PSCustomObject]@{ DisplayName = $AppRegistration.DisplayName Type = "App Registration" } @@ -5293,7 +5293,7 @@ function Get-ObjectInfo { } $AdministrativeUnit = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/directory/administrativeUnits/$ObjectID" -QueryParameters $QueryParameters -BetaAPI -Suppress404 -UserAgent $($GlobalAuditSummary.UserAgent.Name) if ($AdministrativeUnit) { - $object = [PSCustomObject]@{ + $object = [PSCustomObject]@{ DisplayName = $AdministrativeUnit.DisplayName Type = "Administrative Unit" } @@ -5309,7 +5309,7 @@ function Get-ObjectInfo { } $user = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/users/$ObjectID" -QueryParameters $QueryParameters -BetaAPI -Suppress404 -UserAgent $($GlobalAuditSummary.UserAgent.Name) if ($user) { - $object = [PSCustomObject]@{ + $object = [PSCustomObject]@{ DisplayName = $user.DisplayName UserPrincipalName = $user.UserPrincipalName Type = "User" @@ -5330,10 +5330,10 @@ function Get-ObjectInfo { '$select' = "Id,DisplayName,SecurityEnabled,IsAssignableToRole" } $group = Send-GraphRequest -AccessToken $GLOBALMsGraphAccessToken.access_token -Method GET -Uri "/groups/$ObjectID" -QueryParameters $QueryParameters -BetaAPI -Suppress404 -UserAgent $($GlobalAuditSummary.UserAgent.Name) - + if ($group) { $IsAssignabletoRole = if ($null -ne $group.IsAssignableToRole) { $group.IsAssignableToRole } else { $false } - $object = [PSCustomObject]@{ + $object = [PSCustomObject]@{ DisplayName = $group.DisplayName Type = "Group" SecurityEnabled = $group.SecurityEnabled @@ -5342,12 +5342,12 @@ function Get-ObjectInfo { $script:ObjectInfoCache[$cacheKey] = $object Return $object - } + } } if ($normalizedType -eq "unknown") { Write-Log -Level Debug -Message "Unknown Object: $ObjectID" - $object = [PSCustomObject]@{ + $object = [PSCustomObject]@{ DisplayName = $ObjectID Type = "Unknown" } @@ -5713,7 +5713,7 @@ function invoke-EntraFalconAuth { $true } } - + SecurityFindings = @{ AuthCode = { $tokens = Invoke-Auth -ClientID '80ccca67-54bd-44ab-8625-4b79c4dc7775' ` @@ -5784,7 +5784,7 @@ function invoke-EntraFalconAuth { $true } - + } PimforGroup = @{ @@ -6033,12 +6033,12 @@ function Show-EntraFalconBanner { ) $banner = @' - ______ __ ______ __ - / ____/___ / /__________ _ / ____/___ _/ /________ ____ + ______ __ ______ __ + / ____/___ / /__________ _ / ____/___ _/ /________ ____ / __/ / __ \/ __/ ___/ __ `/ / /_ / __ `/ / ___/ __ \/ __ \ / /___/ / / / /_/ / / /_/ / / __/ / /_/ / / /__/ /_/ / / / / -/_____/_/ /_/\__/_/ \__,_/ /_/ \__,_/_/\___/\____/_/ /_/ - +/_____/_/ /_/\__/_/ \__,_/ /_/ \__,_/_/\___/\____/_/ /_/ + '@ # Show Banner with color diff --git a/run_EntraFalcon.ps1 b/run_EntraFalcon.ps1 index 4ba3d79..4b04ef4 100644 --- a/run_EntraFalcon.ps1 +++ b/run_EntraFalcon.ps1 @@ -1,4 +1,4 @@ -<# +<# .Synopsis PowerShell-based security assessment tool for Microsoft Entra ID environments. @@ -28,7 +28,7 @@ Useful when CAE breaks the script. .PARAMETER LimitResults - Limits the number of groups or users included in the report. + Limits the number of groups or users included in the report. The limit is applied *after* sorting by risk score, ensuring only the highest-risk groups and users are processed and reported. This helps improve performance and keep the reports usable in large environments. .PARAMETER AuthFlow @@ -51,7 +51,7 @@ Skips the enumeration of PIM for Groups, avoiding the need for a secondary authentication flow. .PARAMETER IncludeMsApps - Includes Microsoft-owned enterprise applications in the enumeration and analysis. + Includes Microsoft-owned enterprise applications in the enumeration and analysis. By default, these are excluded to reduce noise. .PARAMETER LogLevel @@ -70,7 +70,7 @@ .NOTES Author: Christian Feuchter, Compass Security Switzerland AG, https://www.compass-security.com/ - Source: https://github.com/CompassSecurity/EntraFalcon + Source: https://github.com/CompassSecurity/EntraFalcon #> @@ -154,7 +154,7 @@ if (-not (Test-NonWindowsAuthFlowCompatibility -AuthFlow $AuthFlow -ReadmePath ( } #Splat AuthMethods -$Global:GLOBALAuthMethods = @{ +$Global:GLOBALAuthMethods = @{ AuthFlow = $AuthFlow } @@ -163,7 +163,7 @@ if (-not [string]::IsNullOrWhiteSpace($BroCiToken)) { # Access tokens (JWT) typically start with 'ey' if ($BroCiToken.StartsWith("ey")) { Write-Error "Invalid -BroCiToken: access token (JWT) detected. A refresh token is required." -ErrorAction Stop - } + } # Must look like a refresh token (Azure refresh tokens usually start with "1.") if (-not $BroCiToken.StartsWith("1.")) { From 6f468da73af70a32dbf21449d9e70c21c03bc7f4 Mon Sep 17 00:00:00 2001 From: StrongWind1 <5987034+StrongWind1@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:44:09 -0400 Subject: [PATCH 2/6] Fix null comparison operand order in check_Tenant Swap $consentCount -eq $null to $null -eq $consentCount in the consent count fallback logic (lines 4228-4229, 4672-4673). When $null is on the right side of -eq and the left operand is an array, PowerShell filters the array instead of performing a null check. Placing $null on the left ensures correct behavior regardless of the operand type. Rule: PSPossibleIncorrectComparisonWithNull (Warning) --- modules/check_Tenant.psm1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/check_Tenant.psm1 b/modules/check_Tenant.psm1 index 59c902f..171b351 100644 --- a/modules/check_Tenant.psm1 +++ b/modules/check_Tenant.psm1 @@ -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)" From 5c0ba9b14c3dcec68e8ed275fcd982f42caa8394 Mon Sep 17 00:00:00 2001 From: StrongWind1 <5987034+StrongWind1@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:45:25 -0400 Subject: [PATCH 3/6] Remove unused variables flagged by PSScriptAnalyzer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove variables that are assigned but never read: - check_EnterpriseApps.psm1: $Owners (line 382), $EntraConnectApp (line 755) — flag set but never checked, warning added directly - check_PIM.psm1: $CapIssues (line 486) — boolean never read - EntraTokenAid.psm1: $AuthError init (line 210), $xms_cc (line 1645) — value extracted but never used - shared_Functions.psm1: $AuthCheck (line 5078) — auth test result discarded, error handling is in the catch block - Send-GraphBatchRequest.psm1: $ResultsList and $MoreNextLinks (lines 346-347) — lists initialized but never populated Rule: PSUseDeclaredVarsMoreThanAssignments (Warning) --- modules/EntraTokenAid.psm1 | 5 ----- modules/Send-GraphBatchRequest.psm1 | 3 --- modules/check_EnterpriseApps.psm1 | 4 ---- modules/check_PIM.psm1 | 1 - modules/shared_Functions.psm1 | 2 +- 5 files changed, 1 insertion(+), 14 deletions(-) diff --git a/modules/EntraTokenAid.psm1 b/modules/EntraTokenAid.psm1 index f17450a..f24b463 100644 --- a/modules/EntraTokenAid.psm1 +++ b/modules/EntraTokenAid.psm1 @@ -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" @@ -1642,9 +1640,6 @@ 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 { diff --git a/modules/Send-GraphBatchRequest.psm1 b/modules/Send-GraphBatchRequest.psm1 index 020f09a..67283e5 100644 --- a/modules/Send-GraphBatchRequest.psm1 +++ b/modules/Send-GraphBatchRequest.psm1 @@ -343,9 +343,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 diff --git a/modules/check_EnterpriseApps.psm1 b/modules/check_EnterpriseApps.psm1 index bf144dd..f0828e0 100644 --- a/modules/check_EnterpriseApps.psm1 +++ b/modules/check_EnterpriseApps.psm1 @@ -379,7 +379,6 @@ function Invoke-CheckEnterpriseApps { $WarningsHighPermission = $null $WarningsDangerousPermission = $null $WarningsMediumPermission = $null - $Owners = $null $AppCredentials = @() $OwnerUserDetails = @() $OwnerSPDetails = @() @@ -752,10 +751,7 @@ function Invoke-CheckEnterpriseApps { # 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 diff --git a/modules/check_PIM.psm1 b/modules/check_PIM.psm1 index aa712b0..eeee7ec 100644 --- a/modules/check_PIM.psm1 +++ b/modules/check_PIM.psm1 @@ -483,7 +483,6 @@ function Invoke-CheckPIM { } if ($Issues.Count -gt 0) { - $CapIssues = $true $AuthContextIssueSummary.Add("CAP '$($policy.DisplayName)' (AuthContext:$($policy.AuthContextId -join ', ')): $($Issues -join ' / ')") } diff --git a/modules/shared_Functions.psm1 b/modules/shared_Functions.psm1 index 95525f7..d076b88 100644 --- a/modules/shared_Functions.psm1 +++ b/modules/shared_Functions.psm1 @@ -5075,7 +5075,7 @@ function Get-PimforGroupsAssignments { } try { - $AuthCheck = Send-GraphRequest -AccessToken $GLOBALPimForGroupAccessToken.access_token -Method GET -Uri '/me?$select=id' -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name) -erroraction Stop + $null = Send-GraphRequest -AccessToken $GLOBALPimForGroupAccessToken.access_token -Method GET -Uri '/me?$select=id' -BetaAPI -UserAgent $($GlobalAuditSummary.UserAgent.Name) -erroraction Stop } catch { write-host "[!] Auth error: $($_.Exception.Message -split '\n')" $ResultAuthCheck = $false From ac5a51442d761b681dfcefbbc457db0d1b7dd6d2 Mon Sep 17 00:00:00 2001 From: StrongWind1 <5987034+StrongWind1@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:46:51 -0400 Subject: [PATCH 4/6] Add verbose logging to empty catch blocks Replace 8 empty catch blocks with Write-Verbose messages that log the suppressed exception. These catches are intentional (best-effort parsing of HTTP status codes, response bodies, date strings, and OS detection) but the empty blocks masked errors during debugging. The verbose messages only appear when -Verbose is used, so there is zero impact on normal operation. Affected files: - Send-ApiRequest.psm1: HTTP status, response body, JSON parsing - check_Tenant.psm1: date string parsing (4 instances) - shared_Functions.psm1: OS detection fallback Rule: PSAvoidUsingEmptyCatchBlock (Warning) --- modules/Send-ApiRequest.psm1 | 6 ++++++ modules/check_Tenant.psm1 | 8 ++++---- modules/shared_Functions.psm1 | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/modules/Send-ApiRequest.psm1 b/modules/Send-ApiRequest.psm1 index 5ac430e..50ed8f9 100644 --- a/modules/Send-ApiRequest.psm1 +++ b/modules/Send-ApiRequest.psm1 @@ -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/check_Tenant.psm1 b/modules/check_Tenant.psm1 index 171b351..e2a7de3 100644 --- a/modules/check_Tenant.psm1 +++ b/modules/check_Tenant.psm1 @@ -3908,7 +3908,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" } } } } @@ -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" } } } } @@ -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" } } } } diff --git a/modules/shared_Functions.psm1 b/modules/shared_Functions.psm1 index d076b88..ff0daf4 100644 --- a/modules/shared_Functions.psm1 +++ b/modules/shared_Functions.psm1 @@ -3628,7 +3628,8 @@ function Get-EntraFalconHostOs { if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) { return "Linux" } if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) { return "macOS" } } catch { - # Continue with string-based fallback. + # Continue with string-based fallback + Write-Verbose "RuntimeInformation not available, using string-based OS detection" } $osString = [string]$PSVersionTable.OS From c335730dddcb508f4c3e60999e065539ed8baa00 Mon Sep 17 00:00:00 2001 From: StrongWind1 <5987034+StrongWind1@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:50:00 -0400 Subject: [PATCH 5/6] Suppress PSReviewUnusedParameter false positives Add SuppressMessageAttribute for 14 parameters flagged as unused. Most are false positives where parameters are accessed by nested functions via parent scope (check_Roles, check_AppRegistrations, check_CAPs use inner Get-ObjectDetails/GetObjectInfo functions). Others are part of the shared module calling interface where the orchestrator passes all data to every module uniformly. Each suppression includes a comment explaining why the parameter is accepted but not directly referenced. Rule: PSReviewUnusedParameter (Warning) --- modules/Send-GraphBatchRequest.psm1 | 3 +++ modules/check_AppRegistrations.psm1 | 2 ++ modules/check_CAPs.psm1 | 3 +++ modules/check_Groups.psm1 | 4 +++- modules/check_PIM.psm1 | 2 ++ modules/check_Roles.psm1 | 8 ++++++++ modules/check_Users.psm1 | 2 ++ 7 files changed, 23 insertions(+), 1 deletion(-) diff --git a/modules/Send-GraphBatchRequest.psm1 b/modules/Send-GraphBatchRequest.psm1 index 67283e5..600a493 100644 --- a/modules/Send-GraphBatchRequest.psm1 +++ b/modules/Send-GraphBatchRequest.psm1 @@ -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, diff --git a/modules/check_AppRegistrations.psm1 b/modules/check_AppRegistrations.psm1 index 0784e64..fb8fe18 100644 --- a/modules/check_AppRegistrations.psm1 +++ b/modules/check_AppRegistrations.psm1 @@ -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 = ".", diff --git a/modules/check_CAPs.psm1 b/modules/check_CAPs.psm1 index 53411ff..cf062bf 100644 --- a/modules/check_CAPs.psm1 +++ b/modules/check_CAPs.psm1 @@ -5,6 +5,9 @@ #> 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 = ".", diff --git a/modules/check_Groups.psm1 b/modules/check_Groups.psm1 index a18630c..edcad3b 100644 --- a/modules/check_Groups.psm1 +++ b/modules/check_Groups.psm1 @@ -141,6 +141,7 @@ function Invoke-CheckGroups { $NestedGroupCache = @{} function Expand-NestedGroups-Cached { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'CallerPSCmdlet')] param ( [Parameter(Mandatory = $true)] [object]$StartGroup, @@ -148,7 +149,8 @@ function Invoke-CheckGroups { [Parameter(Mandatory = $true)] [hashtable]$GroupLookup, - [Parameter(Mandatory = $true)] + # CallerPSCmdlet reserved for future ShouldProcess support + [Parameter(Mandatory = $false)] [System.Management.Automation.PSCmdlet]$CallerPSCmdlet ) diff --git a/modules/check_PIM.psm1 b/modules/check_PIM.psm1 index eeee7ec..afce3cd 100644 --- a/modules/check_PIM.psm1 +++ b/modules/check_PIM.psm1 @@ -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 = ".", diff --git a/modules/check_Roles.psm1 b/modules/check_Roles.psm1 index 6b42369..5e0d1ae 100644 --- a/modules/check_Roles.psm1 +++ b/modules/check_Roles.psm1 @@ -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, diff --git a/modules/check_Users.psm1 b/modules/check_Users.psm1 index 52ecd23..9c1693e 100644 --- a/modules/check_Users.psm1 +++ b/modules/check_Users.psm1 @@ -5,6 +5,8 @@ #> 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 = ".", From 9ca1b8ead4a8a3ab9bee727ba9abfd3bb429f002 Mon Sep 17 00:00:00 2001 From: StrongWind1 <5987034+StrongWind1@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:50:35 -0400 Subject: [PATCH 6/6] Rename functions to use approved PowerShell verbs - check_CAPs.psm1: Is-Empty -> Test-IsEmpty 'Is' is not an approved verb; 'Test' is the standard for boolean checks. - check_PIM.psm1: Parse-ISO8601Duration -> ConvertFrom-ISO8601Duration 'Parse' is not an approved verb; 'ConvertFrom' is the standard for converting input formats to PowerShell objects. Both are internal functions with all call sites updated. Rule: PSUseApprovedVerbs (Warning) --- modules/check_CAPs.psm1 | 10 +++++----- modules/check_PIM.psm1 | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/check_CAPs.psm1 b/modules/check_CAPs.psm1 index cf062bf..1665cfe 100644 --- a/modules/check_CAPs.psm1 +++ b/modules/check_CAPs.psm1 @@ -37,7 +37,7 @@ function Invoke-CheckCaps { } # 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 "") { @@ -46,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 } } @@ -55,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 } } @@ -174,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 @@ -188,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 } diff --git a/modules/check_PIM.psm1 b/modules/check_PIM.psm1 index afce3cd..2eca987 100644 --- a/modules/check_PIM.psm1 +++ b/modules/check_PIM.psm1 @@ -23,7 +23,7 @@ function Invoke-CheckPIM { ############################## Function section ######################## #Function to parse ISO8601 used in PIM - function Parse-ISO8601Duration { + function ConvertFrom-ISO8601Duration { param ( [string]$DurationString, @@ -237,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 @@ -251,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 { @@ -271,7 +271,7 @@ 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 {