modules/Invoke-Azqr.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Wrapper for Azure Quick Review (azqr) CLI. .DESCRIPTION Scans an Azure subscription with azqr and returns findings as a PSObject. If azqr is not installed, writes a warning and returns an empty result. Never throws — designed for graceful degradation in the orchestrator. .PARAMETER SubscriptionId The Azure subscription ID to scan. .PARAMETER OutputPath Directory where azqr writes its output. Defaults to .\output\azqr. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SubscriptionId, [string] $OutputPath = (Join-Path (Get-Location) 'output' 'azqr') ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } $missingToolPath = Join-Path $PSScriptRoot 'shared' 'MissingTool.ps1' if (Test-Path $missingToolPath) { . $missingToolPath } $envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param([string]$Text) return $Text } } # Bootstrap Invoke-WithTimeout for CLI timeout protection $cliTimeoutPath = Join-Path $PSScriptRoot 'shared' 'CliTimeout.ps1' if (Test-Path $cliTimeoutPath) { . $cliTimeoutPath } function Test-AzqrInstalled { $null -ne (Get-Command azqr -ErrorAction SilentlyContinue) } function Get-AzqrToolVersion { try { $rawVersion = azqr --version 2>&1 $exitCode = if (Test-Path variable:LASTEXITCODE) { $LASTEXITCODE } else { 0 } if ($exitCode -ne 0) { return '' } $versionText = if ($rawVersion -is [array]) { ($rawVersion -join ' ') } else { [string]$rawVersion } $match = [regex]::Match($versionText, '(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9\.-]+)?)') if ($match.Success) { return $match.Groups[1].Value } return $versionText.Trim() } catch { return '' } } function Get-PropertyValue { param ([object]$Obj, [string]$Name, [object]$Default = $null) if ($null -eq $Obj) { return $Default } $p = $Obj.PSObject.Properties[$Name] if ($null -eq $p -or $null -eq $p.Value) { return $Default } return $p.Value } function Convert-ToStringArray { param ([object]$Value) if ($null -eq $Value) { return @() } $items = [System.Collections.Generic.List[string]]::new() if ($Value -is [string]) { if (-not [string]::IsNullOrWhiteSpace($Value)) { $items.Add($Value.Trim()) } } else { foreach ($item in @($Value)) { if ($null -eq $item) { continue } $text = [string]$item if (-not [string]::IsNullOrWhiteSpace($text)) { $items.Add($text.Trim()) } } } return @($items) } function Resolve-AzqrPillar { param ([object]$Finding) $rawPillar = [string](Get-PropertyValue $Finding 'Pillar' (Get-PropertyValue $Finding 'WafPillar' (Get-PropertyValue $Finding 'WellArchitectedPillar' ''))) if (-not [string]::IsNullOrWhiteSpace($rawPillar)) { switch -Regex ($rawPillar.Trim().ToLowerInvariant()) { '^security$' { return 'Security' } '^reliability$' { return 'Reliability' } '^cost|costoptimization|cost optimization$' { return 'CostOptimization' } '^performance|performanceefficiency|performance efficiency$' { return 'PerformanceEfficiency' } '^operational|operationalexcellence|operational excellence|operations$' { return 'OperationalExcellence' } default { return $rawPillar.Trim() } } } $category = [string](Get-PropertyValue $Finding 'Category' (Get-PropertyValue $Finding 'ServiceCategory' '')) switch -Regex ($category.Trim().ToLowerInvariant()) { 'security|identity|networking|encryption' { return 'Security' } 'reliability|highavailability|high availability|businesscontinuity' { return 'Reliability' } 'cost|finops' { return 'CostOptimization' } 'performance' { return 'PerformanceEfficiency' } 'monitoring|monitoringandalerting|operational|operations|operationalexcellence' { return 'OperationalExcellence' } default { return '' } } } function Resolve-AzqrFrameworks { param ( [object]$Finding, [string]$Pillar ) $frameworks = [System.Collections.Generic.List[hashtable]]::new() foreach ($entry in @(Get-PropertyValue $Finding 'Frameworks' @())) { if ($entry -is [System.Collections.IDictionary]) { $kind = [string]($entry['kind'] ?? $entry['Kind']) $controlId = [string]($entry['controlId'] ?? $entry['ControlId']) if (-not [string]::IsNullOrWhiteSpace($kind) -and -not [string]::IsNullOrWhiteSpace($controlId)) { $frameworks.Add(@{ kind = $kind; controlId = $controlId }) | Out-Null } } elseif ($entry) { $text = [string]$entry if (-not [string]::IsNullOrWhiteSpace($text)) { $frameworks.Add(@{ kind = 'WAF'; controlId = $text.Trim() }) | Out-Null } } } if (-not [string]::IsNullOrWhiteSpace($Pillar)) { $frameworks.Add(@{ kind = 'WAF'; controlId = $Pillar }) | Out-Null } return @($frameworks) } if (-not (Test-AzqrInstalled)) { Write-MissingToolNotice -Tool 'azqr' -Message "azqr is not installed. Skipping Azqr scan. Install from https://azure.github.io/azqr" return [PSCustomObject]@{ Source = 'azqr' SchemaVersion = '1.0' Status = 'Skipped' Message = 'azqr not installed' Findings = @() Errors = @() } } if (-not (Test-Path $OutputPath)) { $null = New-Item -ItemType Directory -Path $OutputPath -Force } try { Write-Verbose "Running azqr scan for subscription $SubscriptionId" $toolVersion = Get-AzqrToolVersion $azqrArgs = @('scan', '--subscription-id', $SubscriptionId, '--output-dir', $OutputPath) $azqrExec = Invoke-WithTimeout -Command 'azqr' -Arguments $azqrArgs -TimeoutSec 300 if ($azqrExec.Output) { Write-Verbose "azqr output: $($azqrExec.Output)" } if ([int]$azqrExec.ExitCode -eq -1) { Write-Warning 'azqr timed out after 300 seconds' return [PSCustomObject]@{ Source = 'azqr' SchemaVersion = '1.0' Status = 'Failed' Message = 'azqr timed out after 300 seconds' Findings = @() Errors = @() } } $jsonFiles = Get-ChildItem -Path $OutputPath -Filter '*.json' -ErrorAction SilentlyContinue $findings = @() foreach ($file in $jsonFiles) { try { $data = Get-Content -Raw $file.FullName | ConvertFrom-Json -ErrorAction Stop $records = if ($data -is [array]) { @($data) } elseif ($null -ne $data) { @($data) } else { @() } foreach ($record in $records) { $pillar = Resolve-AzqrPillar -Finding $record $frameworks = Resolve-AzqrFrameworks -Finding $record -Pillar $pillar $recordVersion = [string](Get-PropertyValue $record 'ToolVersion' $toolVersion) $mitreTactics = Convert-ToStringArray (Get-PropertyValue $record 'MitreTactics' (Get-PropertyValue $record 'Tactics' @())) $mitreTechniques = Convert-ToStringArray (Get-PropertyValue $record 'MitreTechniques' (Get-PropertyValue $record 'Techniques' @())) $findings += [PSCustomObject]@{ Source = [string](Get-PropertyValue $record 'Source' 'azqr') Id = [string](Get-PropertyValue $record 'Id' '') ResourceId = [string](Get-PropertyValue $record 'ResourceId' (Get-PropertyValue $record 'Id' '')) Category = [string](Get-PropertyValue $record 'Category' (Get-PropertyValue $record 'ServiceCategory' 'General')) Title = [string](Get-PropertyValue $record 'Title' (Get-PropertyValue $record 'Recommendation' '')) Recommendation = [string](Get-PropertyValue $record 'Recommendation' '') RecommendationId = [string](Get-PropertyValue $record 'RecommendationId' (Get-PropertyValue $record 'RuleId' '')) Compliant = Get-PropertyValue $record 'Compliant' $false Severity = [string](Get-PropertyValue $record 'Severity' (Get-PropertyValue $record 'Risk' 'Info')) Detail = [string](Get-PropertyValue $record 'Detail' (Get-PropertyValue $record 'Description' '')) LearnMoreUrl = [string](Get-PropertyValue $record 'LearnMoreUrl' (Get-PropertyValue $record 'Url' '')) Remediation = [string](Get-PropertyValue $record 'Remediation' '') Impact = [string](Get-PropertyValue $record 'Impact' '') Effort = [string](Get-PropertyValue $record 'Effort' '') DeepLinkUrl = [string](Get-PropertyValue $record 'DeepLinkUrl' (Get-PropertyValue $record 'PortalUrl' '')) Pillar = $pillar Frameworks = @($frameworks) MitreTactics = @($mitreTactics) MitreTechniques = @($mitreTechniques) RemediationSnippets = @(Get-PropertyValue $record 'RemediationSnippets' @()) EvidenceUris = @(Convert-ToStringArray (Get-PropertyValue $record 'EvidenceUris' @())) BaselineTags = @(Convert-ToStringArray (Get-PropertyValue $record 'BaselineTags' @())) EntityRefs = @(Convert-ToStringArray (Get-PropertyValue $record 'EntityRefs' @())) ToolVersion = $recordVersion SchemaVersion = '1.0' } } } catch { Write-Warning "Could not parse azqr output file $($file.Name): $(Remove-Credentials -Text ([string]$_))" } } return [PSCustomObject]@{ Source = 'azqr' SchemaVersion = '1.0' Status = 'Success' Message = '' ToolVersion = $toolVersion Findings = @($findings) Errors = @() } } catch { Write-Warning "azqr scan failed: $(Remove-Credentials -Text ([string]$_))" return [PSCustomObject]@{ Source = 'azqr' SchemaVersion = '1.0' Status = 'Failed' Message = Remove-Credentials -Text ([string]$_) Findings = @() Errors = @() } } |