modules/Invoke-PSRule.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Wrapper for PSRule for Azure. .DESCRIPTION Runs PSRule.Rules.Azure against a subscription or IaC path. Returns PSObject array of rule violations. If PSRule is not installed, writes a warning and returns empty result. Never throws. .PARAMETER SubscriptionId Azure subscription ID to evaluate. Used for live Azure resource evaluation. .PARAMETER Path Path to IaC files (ARM templates, Bicep) for static analysis. Mutually exclusive with SubscriptionId. #> [CmdletBinding(DefaultParameterSetName = 'Subscription')] param ( [Parameter(Mandatory, ParameterSetName = 'Subscription')] [ValidateNotNullOrEmpty()] [string] $SubscriptionId, [Parameter(Mandatory, ParameterSetName = 'Path')] [ValidateNotNullOrEmpty()] [string] $Path ) 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 } } function Test-PSRuleInstalled { $null -ne (Get-Module -Name PSRule -ListAvailable -ErrorAction SilentlyContinue) -and $null -ne (Get-Module -Name PSRule.Rules.Azure -ListAvailable -ErrorAction SilentlyContinue) } function Get-PSRuleToolVersion { $module = Get-Module -Name PSRule.Rules.Azure -ListAvailable -ErrorAction SilentlyContinue | Sort-Object -Property Version -Descending | Select-Object -First 1 if ($module -and $module.Version) { return [string]$module.Version } return '' } function Convert-PSRuleLevelToSeverity { param ( [Parameter(Mandatory)] [string] $Level ) switch -Regex ($Level.ToLowerInvariant()) { 'critical' { return 'Critical' } 'error|high' { return 'High' } 'warning|medium' { return 'Medium' } 'low' { return 'Low' } 'information|info' { return 'Info' } default { return 'Medium' } } } function Get-PSRuleAnnotationValue { param ( [Parameter(Mandatory)] [object] $Annotations, [Parameter(Mandatory)] [string[]] $KeyHints ) if ($null -eq $Annotations) { return $null } foreach ($property in $Annotations.PSObject.Properties) { $name = [string]$property.Name foreach ($hint in $KeyHints) { if ($name -match $hint) { return $property.Value } } } return $null } function ConvertTo-StringArray { param([object] $Value) if ($null -eq $Value) { return @() } if ($Value -is [string]) { if ([string]::IsNullOrWhiteSpace($Value)) { return @() } return @($Value.Trim()) } if ($Value -is [System.Collections.IEnumerable]) { return @($Value | ForEach-Object { if ($null -eq $_) { return } $candidate = [string]$_ if (-not [string]::IsNullOrWhiteSpace($candidate)) { $candidate.Trim() } } | Where-Object { $_ } | Select-Object -Unique) } return @([string]$Value) } if (-not (Test-PSRuleInstalled)) { Write-MissingToolNotice -Tool 'psrule' -Message "PSRule.Rules.Azure is not installed. Skipping PSRule scan. Run: Install-Module PSRule.Rules.Azure" return [PSCustomObject]@{ Source = 'psrule' Status = 'Skipped' Message = 'PSRule.Rules.Azure not installed' Findings = @() Errors = @() } } try { $toolVersion = Get-PSRuleToolVersion $invokeParams = @{ Module = 'PSRule.Rules.Azure' } if ($PSCmdlet.ParameterSetName -eq 'Path') { Write-Verbose "Running PSRule on path: $Path" $invokeParams['InputPath'] = $Path } else { Write-Verbose "Running PSRule for subscription: $SubscriptionId" $invokeParams['Option'] = @{ 'Configuration.AZURE_SUBSCRIPTION_ID' = $SubscriptionId } } $results = Invoke-PSRule @invokeParams -ErrorAction Stop $findings = @($results) | ForEach-Object { $info = $_.Info $ruleName = if ($_.PSObject.Properties['RuleName'] -and $_.RuleName) { [string]$_.RuleName } else { '' } $ruleId = if ($_.PSObject.Properties['RuleId'] -and $_.RuleId) { [string]$_.RuleId } elseif ($ruleName) { $ruleName } else { '' } $title = if ($info -and $info.DisplayName) { $info.DisplayName } else { $ruleName } $detail = if ($_.Detail -and $_.Detail.Reason) { $_.Detail.Reason -join '; ' } else { '' } $learnUrl = if ($ruleId) { "https://azure.github.io/PSRule.Rules.Azure/en/rules/$ruleId/" } else { '' } $deepLinkUrl = $learnUrl $annotations = if ($info -and $info.Annotations) { $info.Annotations } else { $null } $onlineVersion = Get-PSRuleAnnotationValue -Annotations $annotations -KeyHints @('(?i)^online version$', '(?i)url$') if ($onlineVersion) { $learnUrl = [string]$onlineVersion $deepLinkUrl = [string]$onlineVersion } $pillar = '' $pillarValue = Get-PSRuleAnnotationValue -Annotations $annotations -KeyHints @('(?i)waf.*/pillar', '(?i)^pillar$') if ($pillarValue) { $pillar = [string]$pillarValue } $baselineTags = @() if ($info -and $info.PSObject.Properties['Baseline']) { $baselineTags += ConvertTo-StringArray -Value $info.Baseline } $annotationBaselines = Get-PSRuleAnnotationValue -Annotations $annotations -KeyHints @('(?i)baseline') if ($annotationBaselines) { $baselineTags += ConvertTo-StringArray -Value $annotationBaselines } $baselineTags = @($baselineTags | Select-Object -Unique) $remediation = if ($info -and $info.Recommendation) { $info.Recommendation } else { '' } $frameworks = @() if ($ruleName) { $frameworks = @( @{ Name = 'WAF' Controls = @($ruleName) } ) } $outcome = '' if ($_.PSObject.Properties['Outcome'] -and $_.Outcome) { $outcome = [string]$_.Outcome } $isCompliant = ($outcome -eq 'Pass') $level = if ($_.PSObject.Properties['Level'] -and $_.Level) { [string]$_.Level } else { 'Warning' } $severity = if ($isCompliant) { 'Info' } else { Convert-PSRuleLevelToSeverity -Level $level } [PSCustomObject]@{ Source = 'psrule' Title = $title Category = $ruleName RuleId = $ruleId Compliant = $isCompliant Severity = $severity Detail = $detail ResourceId = if ($_.TargetName -match '^/subscriptions/') { $_.TargetName } else { '' } LearnMoreUrl = $learnUrl DeepLinkUrl = $deepLinkUrl Remediation = $remediation Pillar = $pillar Frameworks = $frameworks BaselineTags = $baselineTags ToolVersion = $toolVersion SchemaVersion = '1.0' } } return [PSCustomObject]@{ Source = 'psrule' Status = 'Success' Message = '' Findings = @($findings) Errors = @() } } catch { Write-Warning "PSRule scan failed: $(Remove-Credentials -Text ([string]$_))" return [PSCustomObject]@{ Source = 'psrule' Status = 'Failed' Message = Remove-Credentials -Text ([string]$_) Findings = @() Errors = @() } } |