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 { "
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:
Do not allow user consentReferences:
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 @"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 {