Private/CheckHelpers.ps1
|
# Helper functions used by all check implementations function New-GraphPermissionMessage { <# .SYNOPSIS Standard error message for checks that require a Graph API application permission the az CLI cannot acquire. #> param( [Parameter(Mandatory)][string]$Permission, [string]$ManualCheck ) $msg = "Requires '$Permission' permission. The audit identity's token cannot acquire this scope. Fix: create an app registration with $Permission (application permission) > Grant admin consent > authenticate with Connect-AzAccount -ServicePrincipal or az login --service-principal." if ($ManualCheck) { $msg += " Manual check: $ManualCheck" } return $msg } function Get-PrefetchData { <# .SYNOPSIS O(1) lookup into prefetched Resource Graph data indexed by key + subscription ID. Returns array of records (or empty array if not found). #> param( [hashtable]$PrefetchData, [string]$Key, [string]$SubscriptionId ) if (-not $PrefetchData.ContainsKey($Key)) { return @() } $bySubscription = $PrefetchData[$Key] if ($bySubscription.ContainsKey('__error')) { return @() } $sidLower = $SubscriptionId.ToLower() if (-not $bySubscription.ContainsKey($sidLower)) { return @() } $items = $bySubscription[$sidLower] if ($null -eq $items) { return @() } return @($items) } function Get-PrefetchError { <# .SYNOPSIS Return the prefetch failure message for a key, or $null if the prefetch succeeded. Checks must surface a non-null result as ERROR (never PASS/INFO) so unreadable data is not mistaken for an empty, compliant environment. #> param( [hashtable]$PrefetchData, [string]$Key ) if (-not $PrefetchData -or -not $PrefetchData.ContainsKey($Key)) { return $null } $bySubscription = $PrefetchData[$Key] if ($bySubscription -is [hashtable] -and $bySubscription.ContainsKey('__error')) { return [string]$bySubscription['__error'] } return $null } function Format-AzErrorMessage { <# .SYNOPSIS Translate raw Azure CLI / Graph API error messages into human-readable descriptions. #> param([string]$Message) # If the caller already provided specific permission guidance, don't overwrite it. if ($Message -imatch 'requires .+application permission') { return $Message } # Graph API auth errors — must precede the generic auth pattern so callers that # prefix messages with "Graph API error:" get Entra-specific guidance instead of # the ARM "Grant Reader role on subscription" advice. if ($Message -imatch 'graph' -and $Message -imatch 'AuthorizationFailed|does not have authorization|is not authorized|forbidden|Access denied|does not have.*permission|Insufficient privileges|scopes are missing') { return "Microsoft Graph API permission denied. Required scopes are missing from the token. Ensure the audit identity has the correct Graph API delegated or application permissions. Try: Connect-AzAccount to re-authenticate, or use a service principal with the required Graph API permissions." } if ($Message -imatch 'graph.*error|reports.*permission|beta.*reports') { return "Microsoft Graph API error — the audit identity may lack the required Graph API permission for this endpoint." } if ($Message -imatch 'AuthorizationFailed|does not have authorization|is not authorized|forbidden|Access denied|does not have.*permission') { return "Insufficient permissions — the audit identity lacks Reader access to this resource. Grant Reader role on the subscription or resource." } if ($Message -imatch 'firewall|network acl|network rule|public network access is disabled|not accessible from the current network') { return "Network access blocked — resource has firewall rules that prevent the audit from reading it. Add the audit machine's IP to the resource firewall allowlist." } if ($Message -imatch 'FeatureNotSupportedForAccountType|not supported for this account type|BlobServiceProperties|feature.*not.*supported') { return "Feature not supported for this resource type — control is not applicable to this tier or kind." } if ($Message -imatch 'ResourceNotFound|\(404\)|was not found|does not exist') { return "Resource not found — it may have been deleted or the resource type is not deployed in this subscription." } if ($Message -imatch 'TooManyRequests|\(429\)|rate limit|throttl') { return "API rate limit reached — too many requests. Re-run the audit or reduce parallelism with -Parallel 1." } if ($Message -imatch 'ConnectionError|Failed to establish|unable to connect|Name or service not known') { return "Network connectivity error — could not reach Azure API endpoint. Check internet access and proxy settings." } if ($Message -imatch 'JSON parse error') { return "Unexpected API response format — the Azure CLI returned data that could not be parsed." } # Return truncated original if no pattern matched if ($Message.Length -gt 220) { return $Message.Substring(0, 220) + "…" } return $Message } function New-ErrorResult { <# .SYNOPSIS Shorthand factory for ERROR results. Passes the message through Format-AzErrorMessage so raw API error strings are translated into human-readable guidance. Title/Level/Section come from the control catalog unless explicitly overridden. #> param( [Parameter(Mandatory, Position = 0)][string]$ControlId, [Parameter(Position = 1)][string]$Message, [Parameter(Position = 2)][string]$SubscriptionId = "", [Parameter(Position = 3)][string]$SubscriptionName = "", [Parameter(Position = 4)][string]$Resource = "", [string]$Title = "", [int]$Level = 0, [string]$Section = "" ) $msg = Format-AzErrorMessage -Message $Message New-CISResult -ControlId $ControlId -Title $Title -Level $Level -Section $Section ` -Status $script:ERR -Details $msg ` -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName -Resource $Resource } function New-InfoResult { <# .SYNOPSIS Shorthand factory for INFO results (non-assessed — resource not present or not applicable). Title/Level/Section come from the control catalog unless explicitly overridden. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification='Factory function that constructs and returns an object; does not modify system state.')] param( [Parameter(Mandatory, Position = 0)][string]$ControlId, [Parameter(Position = 1)][string]$Message, [Parameter(Position = 2)][string]$SubscriptionId = "", [Parameter(Position = 3)][string]$SubscriptionName = "", [Parameter(Position = 4)][string]$Resource = "", [string]$Title = "", [int]$Level = 0, [string]$Section = "" ) New-CISResult -ControlId $ControlId -Title $Title -Level $Level -Section $Section ` -Status $script:INFO -Details $Message ` -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName -Resource $Resource } function New-ManualResult { <# .SYNOPSIS Shorthand factory for MANUAL results — controls that cannot be automated and require a human reviewer to verify compliance in the portal. Title/Level/Section come from the control catalog unless explicitly overridden. #> param( [Parameter(Mandatory, Position = 0)][string]$ControlId, [Parameter(Position = 1)][string]$Message, [string]$Title = "", [int]$Level = 0, [string]$Section = "" ) New-CISResult -ControlId $ControlId -Title $Title -Level $Level -Section $Section ` -Status $script:MANUAL -Details $Message } function Add-ClassifiedErrorSet { <# .SYNOPSIS Classify one data-plane read failure and emit the same outcome for every control that depended on that read. .DESCRIPTION Sections 8 and 9 repeated a four-branch catch block (not-applicable / authorization / firewall / raw fallback) per check — ~11 near-identical copies. This function owns the classification once: not-applicable produces INFO (only when -NotApplicableMessage is given), authorization and firewall produce ERROR with the caller's remediation text, anything else produces ERROR with the raw message. Control titles/levels/sections come from the catalog. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification='Appends to the provided in-memory result list; does not modify system state.')] param( [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[object]]$Results, [Parameter(Mandatory)][string[]]$ControlIds, [Parameter(Mandatory)][string]$Message, [string]$NotApplicableMessage = "", [string]$AuthzMessage = "Insufficient permissions to read this resource.", [string]$FirewallMessage = "A firewall or network configuration is blocking access from the audit machine.", [string]$SubscriptionId = "", [string]$SubscriptionName = "", [string]$Resource = "" ) foreach ($cid in $ControlIds) { $emit = @{ ControlId = $cid; SubscriptionId = $SubscriptionId; SubscriptionName = $SubscriptionName; Resource = $Resource } if ($NotApplicableMessage -and (Test-NotApplicableError $Message)) { $Results.Add((New-InfoResult @emit -Message $NotApplicableMessage)) } elseif (Test-AuthzError $Message) { $Results.Add((New-ErrorResult @emit -Message $AuthzMessage)) } elseif (Test-FirewallError $Message) { $Results.Add((New-ErrorResult @emit -Message $FirewallMessage)) } else { $Results.Add((New-ErrorResult @emit -Message $Message)) } } } |