Engines/Terraform/Invoke-AvmTerraformCheckPolicy.ps1
|
function Invoke-AvmTerraformCheckPolicy { <# .SYNOPSIS Run 'conftest test' against a Terraform module using the pinned APRL + AVMSEC policy bundles. .DESCRIPTION Engine implementation called by Invoke-AvmCheckPolicy when the module context is Ecosystem='terraform'. Pipeline: 1. Resolve the 'conftest' binary via Resolve-AvmTool (cache first; -AllowPathFallback enables PATH fallback when set). 2. Load the merged pinned-asset config via Read-AvmAssetConfig from $Context.Root (walks upward for .avm/config.json, falls back to <Config>/avm.config.json for per-user defaults). 3. Look up two named asset descriptors by convention: avm-policy-aprl - the Azure Proactive Resiliency Library bundle avm-policy-avmsec - the AVM Security bundle If either is missing, throw AvmConfigurationException with a "declare these in .avm/config.json" message. The dispatcher (Invoke-AvmCheckPolicy via Invoke-AvmPrCheck) maps that to Status='skipped' so the chain still flows for unconfigured repos. 4. Materialise each asset via Resolve-AvmPinnedAsset and capture the on-disk Path. 5. Run conftest: conftest test --policy <APRL> --policy <AVMSEC> [--policy <example-exception>]... --output json --parser hcl2 . from CWD=$Context.Root. Per-example exception bundles are discovered as <Root>/examples/<name>/exceptions/*.rego (top- level glob only) and appended in ordinal-sorted order, so argv is stable across operating systems and locale. 6. Parse the JSON output: an array of per-file/per-namespace records each carrying 'failures' (severity=error) and 'warnings' (severity=warning) lists. Flatten into the shared Issue record shape. conftest exit codes: 0 - no failures (warnings allowed) 1 - at least one failure (parse and report; Status='fail') others - conftest itself misbehaved (throw AvmProcessException) This slice uses the HCL2 parser so the engine can be exercised without first running 'terraform plan' against a configured Azure backend. The plan-JSON path (which APRL was originally designed for) is a deliberate follow-up slice. .PARAMETER Context Module context produced by Get-AvmModuleContext. Must have Ecosystem='terraform'. .PARAMETER AllowPathFallback Pass through to Resolve-AvmTool. .OUTPUTS pscustomobject with Engine, Tool, ToolPath, ToolSource, Status, Issues. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] $Context, [switch] $AllowPathFallback ) Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' if ($Context.Ecosystem -ne 'terraform') { throw [System.ArgumentException]::new( "Invoke-AvmTerraformCheckPolicy requires a terraform context (got Ecosystem='$($Context.Ecosystem)').") } $tool = Resolve-AvmTool -Name 'conftest' -AllowPathFallback:$AllowPathFallback $assetConfig = Read-AvmAssetConfig -Path $Context.Root $aprlName = 'avm-policy-aprl' $avmsecName = 'avm-policy-avmsec' $missing = New-Object System.Collections.Generic.List[string] if (-not $assetConfig.Assets.Contains($aprlName)) { $missing.Add($aprlName) } if (-not $assetConfig.Assets.Contains($avmsecName)) { $missing.Add($avmsecName) } if ($missing.Count -gt 0) { throw [AvmConfigurationException]::new( ("avm check policy requires pinned policy bundles '{0}'. Declare them in .avm/config.json (or your per-user <Config>/avm.config.json) with 'source' + 'sha256' for each." -f ($missing -join "', '"))) } $aprlAsset = Resolve-AvmPinnedAsset -Name $aprlName -Asset $assetConfig.Assets[$aprlName] $avmsecAsset = Resolve-AvmPinnedAsset -Name $avmsecName -Asset $assetConfig.Assets[$avmsecName] # Per-example exceptions: examples/<name>/exceptions/*.rego (top-level glob; # spec wording is one level deep). Each match is appended as an additional # --policy <path> pair after APRL+AVMSEC so the base bundles still win on # ordering. Sorted by FullName with [StringComparer]::Ordinal so argv is # stable across operating systems and locale. $exceptionPolicies = @() $examplesRoot = Join-Path $Context.Root 'examples' if (Test-Path -LiteralPath $examplesRoot -PathType Container) { $exampleDirs = Get-ChildItem -LiteralPath $examplesRoot -Directory -ErrorAction SilentlyContinue $exceptionMatches = New-Object 'System.Collections.Generic.List[string]' foreach ($example in $exampleDirs) { $exceptionsDir = Join-Path $example.FullName 'exceptions' if (-not (Test-Path -LiteralPath $exceptionsDir -PathType Container)) { continue } $regoFiles = Get-ChildItem -LiteralPath $exceptionsDir -File -Filter '*.rego' -ErrorAction SilentlyContinue foreach ($file in $regoFiles) { $exceptionMatches.Add($file.FullName) } } if ($exceptionMatches.Count -gt 0) { $arr = $exceptionMatches.ToArray() [System.Array]::Sort($arr, [System.StringComparer]::Ordinal) $exceptionPolicies = $arr } } $argList = New-Object 'System.Collections.Generic.List[string]' $argList.Add('test') $argList.Add('--policy'); $argList.Add($aprlAsset.Path) $argList.Add('--policy'); $argList.Add($avmsecAsset.Path) foreach ($exception in $exceptionPolicies) { $argList.Add('--policy'); $argList.Add($exception) } $argList.Add('--output'); $argList.Add('json') $argList.Add('--parser'); $argList.Add('hcl2') $argList.Add('.') $result = Invoke-AvmProcess ` -FilePath $tool.Path ` -ArgumentList $argList.ToArray() ` -WorkingDirectory $Context.Root ` -IgnoreExitCode # exit 0 = no failures; 1 = at least one failure; anything else = conftest itself misbehaved. if ($result.ExitCode -ne 0 -and $result.ExitCode -ne 1) { $stderr = if ($result.StdErr) { $result.StdErr.Trim() } else { '' } $tail = if ($stderr) { ": $stderr" } else { '.' } throw [AvmProcessException]::new( ('conftest exited with code {0}{1}' -f $result.ExitCode, $tail)) } $issues = New-Object System.Collections.Generic.List[object] $payload = if ($result.StdOut) { $result.StdOut.Trim() } else { '' } if ($payload) { try { $parsed = $payload | ConvertFrom-Json -ErrorAction Stop } catch { throw [AvmProcessException]::new( "Could not parse conftest --output json output: $($_.Exception.Message)") } foreach ($record in @($parsed)) { if (-not $record) { continue } $file = if ($record.PSObject.Properties['filename']) { [string]$record.filename } else { '' } $namespace = if ($record.PSObject.Properties['namespace']) { [string]$record.namespace } else { '' } if ($record.PSObject.Properties['failures'] -and $record.failures) { foreach ($failure in @($record.failures)) { $msg = if ($failure.PSObject.Properties['msg']) { [string]$failure.msg } else { '' } $issues.Add([pscustomobject][ordered]@{ File = $file Line = 0 Column = 0 Severity = 'error' Code = $namespace Message = $msg }) } } if ($record.PSObject.Properties['warnings'] -and $record.warnings) { foreach ($warning in @($record.warnings)) { $msg = if ($warning.PSObject.Properties['msg']) { [string]$warning.msg } else { '' } $issues.Add([pscustomobject][ordered]@{ File = $file Line = 0 Column = 0 Severity = 'warning' Code = $namespace Message = $msg }) } } } } $status = if ($issues | Where-Object { $_.Severity -eq 'error' }) { 'fail' } else { 'pass' } return [pscustomobject][ordered]@{ Engine = 'terraform' Tool = ('{0}/{1}' -f $tool.Name, $tool.Version) ToolPath = $tool.Path ToolSource = $tool.Source Status = $status Issues = $issues.ToArray() } } |