modules/normalizers/Normalize-Zizmor.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for zizmor findings.
.DESCRIPTION
    Converts raw zizmor wrapper output to v3 FindingRow objects.
    Platform=GitHub, EntityType=Workflow.
#>

[CmdletBinding()]
param ()

. "$PSScriptRoot\..\shared\Schema.ps1"
. "$PSScriptRoot\..\shared\Canonicalize.ps1"

function Normalize-Zizmor {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject] $ToolResult,
        [System.Collections.Generic.List[psobject]] $EdgeCollector
    )

    if ($ToolResult.Status -ne 'Success' -or -not $ToolResult.Findings) {
        return @()
    }

    $runId = [guid]::NewGuid().ToString()
    $normalized = [System.Collections.Generic.List[PSCustomObject]]::new()

    function ConvertTo-StringArray {
        param([object] $Value)
        $values = [System.Collections.Generic.List[string]]::new()
        foreach ($item in @($Value)) {
            if ($null -eq $item) { continue }
            $text = [string]$item
            if ([string]::IsNullOrWhiteSpace($text)) { continue }
            $values.Add($text.Trim())
        }
        return @($values | Select-Object -Unique)
    }

    function Get-ZizmorRuleIdFromTitle {
        param([string] $Title)
        if ([string]::IsNullOrWhiteSpace($Title)) { return '' }
        if ($Title -match '^(?<rule>[a-z0-9-]+)\s*:') {
            return $Matches['rule']
        }
        return ''
    }

    function ConvertTo-RemediationSnippets {
        param([object] $Value)

        $snippets = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($item in @($Value)) {
            if ($null -eq $item) { continue }
            $entry = @{}
            if ($item -is [System.Collections.IDictionary]) {
                foreach ($key in $item.Keys) {
                    if (-not [string]::IsNullOrWhiteSpace([string]$key) -and $null -ne $item[$key]) {
                        $entry[[string]$key] = [string]$item[$key]
                    }
                }
            } else {
                foreach ($prop in @($item.PSObject.Properties)) {
                    if ($null -ne $prop.Value) {
                        $entry[[string]$prop.Name] = [string]$prop.Value
                    }
                }
            }
            if ($entry.Count -eq 0) { continue }
            if (-not $entry.ContainsKey('language')) { $entry['language'] = 'yaml' }
            if (-not ($entry.ContainsKey('code') -or ($entry.ContainsKey('before') -and $entry.ContainsKey('after')))) { continue }
            $snippets.Add($entry) | Out-Null
        }
        return @($snippets)
    }

    function Get-ZizmorEntityId {
        param(
            [object] $Finding,
            [string[]] $EntityRefs,
            [string[]] $EvidenceUris
        )

        foreach ($entityRef in @($EntityRefs)) {
            $normalized = ([string]$entityRef).Trim() -replace '\\', '/'
            if ([string]::IsNullOrWhiteSpace($normalized)) { continue }
            $normalized = $normalized -replace '^https?://github\.com/', ''
            $normalized = $normalized -replace '^\.?/', ''
            if ($normalized -match '^[^/]+/[^/]+/.+') {
                return $normalized.ToLowerInvariant()
            }
        }

        foreach ($evidenceUri in @($EvidenceUris)) {
            if (-not $evidenceUri) { continue }
            if ($evidenceUri -match 'https://github\.com/(?<owner>[^/]+)/(?<repo>[^/]+)/blob/[^/]+/(?<path>[^#]+)') {
                return "$($Matches['owner'])/$($Matches['repo'])/$($Matches['path'])".ToLowerInvariant()
            }
        }

        $rawId = ''
        if ($Finding.PSObject.Properties['ResourceId'] -and $Finding.ResourceId) {
            $rawId = [string]$Finding.ResourceId
        }
        if ($rawId) {
            $rawId = $rawId.Trim().ToLowerInvariant() -replace '\\', '/'
            $rawId = $rawId -replace '^\.\/', ''
            return $rawId
        }

        return "zizmor/$([guid]::NewGuid().ToString())"
    }

    function Get-ZizmorImpactFromSeverity {
        param([string] $Severity)
        switch -Regex (($Severity ?? '').ToLowerInvariant()) {
            'critical' { return 'Critical' }
            'high' { return 'High' }
            'medium|moderate' { return 'Medium' }
            'low' { return 'Low' }
            'info' { return 'Info' }
            default { return 'Medium' }
        }
    }

    function Add-ZizmorTrackAEdges {
        param([object] $Candidate)
        if ($null -eq $EdgeCollector) { return }
        if ($null -eq $Candidate -or -not $Candidate.PSObject.Properties['AttackPathEdges']) { return }
        $allowedRelations = @('TriggeredBy', 'AuthenticatesAs', 'DeploysTo', 'UsesSecret', 'HasFederatedCredential', 'Declares')
        foreach ($edgeHint in @($Candidate.AttackPathEdges)) {
            if ($null -eq $edgeHint) { continue }
            $source = if ($edgeHint.PSObject.Properties['Source']) { [string]$edgeHint.Source } else { '' }
            $target = if ($edgeHint.PSObject.Properties['Target']) { [string]$edgeHint.Target } else { '' }
            $relation = if ($edgeHint.PSObject.Properties['Relation']) { [string]$edgeHint.Relation } else { '' }
            if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($target)) { continue }
            if ($relation -notin $allowedRelations) { continue }
            $edge = New-Edge -Source $source -Target $target -Relation $relation -Confidence 'Likely' -Platform 'GitHub' -DiscoveredBy 'zizmor'
            if ($null -ne $edge) { $EdgeCollector.Add($edge) | Out-Null }
        }
    }

    foreach ($finding in $ToolResult.Findings) {
        Add-ZizmorTrackAEdges -Candidate $finding
        $rawId = if ($finding.PSObject.Properties['ResourceId'] -and $finding.ResourceId) { [string]$finding.ResourceId } else { '' }

        $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) {
            [string]$finding.Id
        } else {
            [guid]::NewGuid().ToString()
        }

        $title = if ($finding.PSObject.Properties['Title'] -and $finding.Title) { $finding.Title } else { 'Unknown' }
        $category = if ($finding.PSObject.Properties['Category'] -and $finding.Category) { $finding.Category } else { 'CI/CD Security' }
        $ruleId = if ($finding.PSObject.Properties['RuleId'] -and $finding.RuleId) { [string]$finding.RuleId } else { Get-ZizmorRuleIdFromTitle -Title ([string]$title) }

        $rawSev = if ($finding.PSObject.Properties['Severity'] -and $finding.Severity) { $finding.Severity } else { 'Medium' }
        $severity = switch -Regex ($rawSev.ToString().ToLowerInvariant()) {
            'critical'         { 'Critical' }
            'high'             { 'High' }
            'medium|moderate'  { 'Medium' }
            'low'              { 'Low' }
            'info'             { 'Info' }
            default            { 'Medium' }
        }

        $compliant = if ($finding.PSObject.Properties['Compliant']) { [bool]$finding.Compliant } else { $false }
        $detail = if ($finding.PSObject.Properties['Detail'] -and $finding.Detail) { $finding.Detail } else { '' }
        $remediation = if ($finding.PSObject.Properties['Remediation'] -and $finding.Remediation) { $finding.Remediation } else { '' }
        $learnMore = if ($finding.PSObject.Properties['LearnMoreUrl'] -and $finding.LearnMoreUrl) { $finding.LearnMoreUrl } else { '' }
        $pillar = if ($finding.PSObject.Properties['Pillar'] -and $finding.Pillar) { [string]$finding.Pillar } else { 'Security' }
        $impact = if ($finding.PSObject.Properties['Impact'] -and $finding.Impact) { [string]$finding.Impact } else { Get-ZizmorImpactFromSeverity -Severity $severity }
        $effort = if ($finding.PSObject.Properties['Effort'] -and $finding.Effort) { [string]$finding.Effort } else { if ($ruleId -eq 'unpinned-uses') { 'Medium' } else { 'Low' } }
        $deepLinkUrl = if ($finding.PSObject.Properties['DeepLinkUrl'] -and $finding.DeepLinkUrl) { [string]$finding.DeepLinkUrl } else { '' }
        $remediationSnippets = if ($finding.PSObject.Properties['RemediationSnippets'] -and $finding.RemediationSnippets) { ConvertTo-RemediationSnippets -Value $finding.RemediationSnippets } else { @() }
        $evidenceUris = if ($finding.PSObject.Properties['EvidenceUris'] -and $finding.EvidenceUris) { ConvertTo-StringArray -Value $finding.EvidenceUris } else { @() }
        $baselineTags = if ($finding.PSObject.Properties['BaselineTags'] -and $finding.BaselineTags) { ConvertTo-StringArray -Value $finding.BaselineTags } else { @() }
        $mitreTactics = if ($finding.PSObject.Properties['MitreTactics'] -and $finding.MitreTactics) { ConvertTo-StringArray -Value $finding.MitreTactics } else { @() }
        $mitreTechniques = if ($finding.PSObject.Properties['MitreTechniques'] -and $finding.MitreTechniques) { ConvertTo-StringArray -Value $finding.MitreTechniques } else { @() }
        $entityRefs = if ($finding.PSObject.Properties['EntityRefs'] -and $finding.EntityRefs) { ConvertTo-StringArray -Value $finding.EntityRefs } else { @() }
        $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } elseif ($ToolResult.PSObject.Properties['ToolVersion'] -and $ToolResult.ToolVersion) { [string]$ToolResult.ToolVersion } else { '' }

        if (@($baselineTags).Count -eq 0) {
            if ($ruleId) { $baselineTags += $ruleId }
            $baselineTags += "severity:$($severity.ToLowerInvariant())"
        }
        if (@($entityRefs).Count -eq 0 -and $rawId) {
            $entityRefs = @($rawId -replace '\\', '/')
        }
        if ([string]::IsNullOrWhiteSpace($deepLinkUrl) -and $ruleId) {
            $deepLinkUrl = "https://docs.zizmor.sh/audits/#$ruleId"
        }
        if ([string]::IsNullOrWhiteSpace($deepLinkUrl) -and @($evidenceUris).Count -gt 0) {
            $deepLinkUrl = $evidenceUris[0]
        }
        if ([string]::IsNullOrWhiteSpace($learnMore) -and -not [string]::IsNullOrWhiteSpace($deepLinkUrl)) {
            $learnMore = $deepLinkUrl
        }
        if (@($mitreTechniques).Count -eq 0) {
            switch (($ruleId ?? '').ToLowerInvariant()) {
                'template-injection' { $mitreTechniques = @('T1059') }
                'expression-injection' { $mitreTechniques = @('T1059') }
                'unpinned-uses' { $mitreTechniques = @('T1195.001') }
            }
        }

        $canonicalId = Get-ZizmorEntityId -Finding $finding -EntityRefs $entityRefs -EvidenceUris $evidenceUris

        $row = New-FindingRow -Id $findingId `
            -Source 'zizmor' -EntityId $canonicalId -EntityType 'Workflow' `
            -Title $title -RuleId $ruleId -Compliant ([bool]$compliant) -ProvenanceRunId $runId `
            -Platform 'GitHub' -Category $category -Severity $severity `
            -Detail $detail -Remediation $remediation `
            -LearnMoreUrl $learnMore -ResourceId ($rawId ?? '') `
            -Pillar $pillar -Impact $impact -Effort $effort `
            -DeepLinkUrl $deepLinkUrl `
            -RemediationSnippets $remediationSnippets `
            -EvidenceUris $evidenceUris `
            -BaselineTags $baselineTags `
            -MitreTactics $mitreTactics `
            -MitreTechniques $mitreTechniques `
            -EntityRefs $entityRefs `
            -ToolVersion $toolVersion
        # Skip null rows (validation failed)
        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}