Private/Sarif.ps1
|
# SARIF 2.1.0 export — lets CI upload findings to GitHub code scanning # (github/codeql-action/upload-sarif) and Defender for DevOps. function New-CISSarifReport { <# .SYNOPSIS Write audit results as a SARIF 2.1.0 log. Mapping: FAIL -> level 'error', ERROR -> level 'warning' (unauditable is still a finding — mirrors the score treating ERROR as failing), SUPPRESSED -> level 'note' with a SARIF suppression object (GitHub shows these as dismissed, not open alerts). PASS/INFO/MANUAL are omitted — SARIF results represent findings, not inventory. Returns the path written. #> param( [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Results, [Parameter(Mandatory)][string]$OutputPath ) $included = @($Results | Where-Object { $_.Status -in @($script:FAIL, $script:ERR, $script:SUPPRESSED) }) # One SARIF rule per control that produced a finding, in first-seen order $ruleIndex = @{} # ControlId -> index into $rules $rules = [System.Collections.Generic.List[object]]::new() foreach ($r in $included) { if ($ruleIndex.ContainsKey($r.ControlId)) { continue } $ruleIndex[$r.ControlId] = $rules.Count $help = if ($r.Remediation) { [string]$r.Remediation } else { [string]$r.Title } $rules.Add([ordered]@{ id = [string]$r.ControlId name = "CIS$(([string]$r.ControlId) -replace '\.', '_')" shortDescription = @{ text = [string]$r.Title } help = @{ text = $help } properties = [ordered]@{ section = [string]$r.Section cisLevel = [int]$r.Level } }) } $sarifResults = [System.Collections.Generic.List[object]]::new() foreach ($r in $included) { $level = switch ($r.Status) { $script:FAIL { 'error' } $script:ERR { 'warning' } $script:SUPPRESSED { 'note' } } $msg = if ($r.Details) { [string]$r.Details } else { [string]$r.Title } # GitHub code scanning requires a physical location; Azure findings have no # source file, so use a stable pseudo-path: azure/<subscription>/<resource>. $segments = @('azure') $segments += if ($r.SubscriptionId) { [string]$r.SubscriptionId } else { 'tenant' } if ($r.Resource) { $segments += [string]$r.Resource } $uri = ($segments | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/' $logicalName = if ($r.Resource) { [string]$r.Resource } elseif ($r.SubscriptionName) { [string]$r.SubscriptionName } else { 'tenant' } $result = [ordered]@{ ruleId = [string]$r.ControlId ruleIndex = [int]$ruleIndex[$r.ControlId] level = $level message = @{ text = $msg } locations = @( [ordered]@{ physicalLocation = [ordered]@{ artifactLocation = @{ uri = $uri } region = @{ startLine = 1 } } logicalLocations = @( [ordered]@{ name = $logicalName fullyQualifiedName = "$(if ($r.SubscriptionName) { $r.SubscriptionName } else { 'tenant' })/$logicalName" kind = 'resource' } ) } ) # Stable alert identity across runs: control + subscription + resource partialFingerprints = @{ cisResultKey = "$($r.ControlId)|$($r.SubscriptionId)|$($r.Resource)" } } if ($r.Status -eq $script:SUPPRESSED) { # Details carries the accepted-risk justification appended by Invoke-Suppressions $result['suppressions'] = @( [ordered]@{ kind = 'external'; justification = [string]$r.Details } ) } $sarifResults.Add($result) } $sarif = [ordered]@{ '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' version = '2.1.0' runs = @( [ordered]@{ tool = @{ driver = [ordered]@{ name = 'CISAzureFoundationsBenchmark' informationUri = 'https://github.com/vegazbabz/CISAzureBenchmark-PS' version = [string]$script:CIS_VERSION semanticVersion = [string]$script:CIS_VERSION rules = @($rules) properties = @{ benchmarkVersion = [string]$script:BENCHMARK_VER } } } results = @($sarifResults) } ) } $json = ConvertTo-Json -InputObject $sarif -Depth 12 # BOM-less UTF-8 — some SARIF consumers reject a byte-order mark [System.IO.File]::WriteAllText($OutputPath, $json, [System.Text.UTF8Encoding]::new($false)) return $OutputPath } |