PSRule/PSRuleRunner.ps1
|
<#
Runs PSRule for Azure in a PowerShell process of its own, for Invoke-AACPSRule - not meant to be run directly. PSRule loads its own YamlDotNet.dll, and .NET can hold only one version of an assembly per process. platyPS, powershell-yaml, Az.Aks and others ship different YamlDotNet versions, so in a session where one of them loaded first, running PSRule fails ("Could not load file or assembly ... YamlDotNet.dll ... manifest definition does not match") - and running PSRule first would break them instead. A child process avoids both. Reads the objects to check from -InputPath (JSON, the Export-AzRuleData shape) and the settings from -SettingPath: Rule only these rules - names or wildcards ExcludeRule leave out these rules - names or wildcards Baseline a PSRule for Azure baseline Configuration PSRule configuration values (PSRule for Azure's AZURE_* options, and custom rules' own) RulePath custom rule files or folders (*.Rule.ps1, *.Rule.yaml, *.Rule.jsonc), run with PSRule for Azure's rules Writes each result, flattened to plain data, to -OutputPath as JSON. While it runs it writes 'PROGRESS <objects taken in>' lines to standard output and 'RULES <count>' once the rules are known; a failure is written as 'ERROR <message>' with exit code 1. #> param( [Parameter(Mandatory)] [string] $ModulePath, [Parameter(Mandatory)] [string] $InputPath, [Parameter(Mandatory)] [string] $SettingPath, [Parameter(Mandatory)] [string] $OutputPath ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' try { $setting = Get-Content -LiteralPath $SettingPath -Raw | ConvertFrom-Json -AsHashtable Import-Module -Name $ModulePath -ErrorAction Stop -Verbose:$false -WarningAction SilentlyContinue $configuration = if ($setting.Configuration) { $setting.Configuration } else { @{} } $rulePath = @($setting.RulePath | Where-Object { $_ }) $include = @($setting.Rule | Where-Object { $_ }) $exclude = @($setting.ExcludeRule | Where-Object { $_ }) $source = @{ Module = 'PSRule.Rules.Azure'; WarningAction = 'SilentlyContinue' } if ($rulePath) { $source.Path = $rulePath } # The rules to run: every rule of PSRule for Azure and the custom rule # files, narrowed by Rule and ExcludeRule - both matched as wildcards, # so 'Azure.Storage.*' or 'AAC.*' work. # PSRule for Azure's binding - what a resource's type and name are - for # every rule, so custom rules can use -Type 'Microsoft.Storage/...' too. $options = @{ 'Execution.UnprocessedObject' = 'Ignore' 'Binding.TargetType' = @('resourceType', 'type') 'Binding.TargetName' = @('ResourceName', 'name') } # The cultures PSRule reads rule help (synopsis, recommendation, link) in: # this session's, then en-US - each with its parents (en-US, then en), as # PSRule looks only in the folders named. Linux often runs with the # invariant culture (LANG=C.UTF-8), which on its own matches no help # folder at all. $cultures = [System.Collections.Generic.List[string]]::new() foreach ($culture in [System.Globalization.CultureInfo]::CurrentUICulture, [System.Globalization.CultureInfo]::GetCultureInfo('en-US')) { for ($c = $culture; $c.Name; $c = $c.Parent) { if (-not $cultures.Contains($c.Name)) { $cultures.Add($c.Name) } } } $options['Output.Culture'] = $cultures.ToArray() $all = @(Get-PSRule @source -Option (New-PSRuleOption -Option $options -Configuration $configuration) -ErrorAction Stop | ForEach-Object { [string]$_.Name } | Select-Object -Unique) $names = @($all | Where-Object { $name = $_ ($include.Count -eq 0 -or @($include | Where-Object { $name -like $_ }).Count -gt 0) -and @($exclude | Where-Object { $name -like $_ }).Count -eq 0 }) [Console]::Out.WriteLine("RULES $($names.Count)") if ($names.Count -eq 0) { [System.IO.File]::WriteAllText($OutputPath, '[]', [System.Text.UTF8Encoding]::new($false)) return } $invoke = @{ Option = (New-PSRuleOption -Option $options -Configuration $configuration) Outcome = 'Pass', 'Fail', 'Error' } + $source # When rules are left out, name the ones to run. if ($names.Count -lt $all.Count) { $invoke.Name = $names } if ($setting.Baseline) { $invoke.Baseline = $setting.Baseline } $objects = @(Get-Content -LiteralPath $InputPath -Raw | ConvertFrom-Json -Depth 100) $step = 0 # A rule that fails on one resource (a setting it didn't expect) must be # that rule's Error result, not the end of the run: under 'Stop' its # error would stop every rule. PSRule records it in the result. $ErrorActionPreference = 'Continue' $results = @($objects | ForEach-Object { # Progress as PSRule takes each object in, in batches. if (++$step % 10 -eq 0) { [Console]::Out.WriteLine("PROGRESS $step") } $_ } | Invoke-PSRule @invoke -ErrorAction SilentlyContinue) $ErrorActionPreference = 'Stop' $field = { param($Object, [string] $Name) if ($null -eq $Object) { return '' } $property = $Object.PSObject.Properties[$Name] if ($property -and $null -ne $property.Value) { [string]$property.Value } else { '' } } $flat = @(foreach ($result in $results) { $info = $result.Info $annotations = if ($info) { $info.Annotations } else { $null } $target = $result.TargetObject [ordered]@{ RuleName = [string]$result.RuleName ModuleName = $(if ($info) { [string]$info.ModuleName } else { '' }) Ref = [string]$result.Ref Outcome = [string]$result.Outcome Level = [string]$result.Level Reason = @($result.Reason | Where-Object { $_ } | ForEach-Object { ([string]$_).Trim() }) Recommendation = (([string]$result.Recommendation) -replace '\s+', ' ').Trim() ErrorMessage = $(if ($result.Error) { [string]$result.Error.Message } else { '' }) DisplayName = $(if ($info) { [string]$info.DisplayName } else { '' }) Synopsis = $(if ($info) { [string]$info.Synopsis } else { '' }) Link = $(if ($annotations -and $annotations['online version']) { [string]$annotations['online version'] } else { '' }) Severity = $(if ($annotations -and $annotations['severity']) { [string]$annotations['severity'] } else { '' }) Pillar = $(if ($result.Tag) { [string]$result.Tag['Azure.WAF/pillar'] } else { '' }) TargetName = [string]$result.TargetName Name = & $field $target 'name' Id = & $field $target 'id' Type = & $field $target 'type' ResourceGroupName = & $field $target 'resourceGroupName' SubscriptionId = & $field $target 'subscriptionId' } }) [System.IO.File]::WriteAllText($OutputPath, (ConvertTo-Json -InputObject $flat -Depth 5 -Compress), [System.Text.UTF8Encoding]::new($false)) [Console]::Out.WriteLine("PROGRESS $($objects.Count)") } catch { [Console]::Out.WriteLine("ERROR $($_.Exception.Message -replace '\s+', ' ')") exit 1 } |