modules/Invoke-Zizmor.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Wrapper for zizmor CLI (GitHub Actions YAML scanner).
.DESCRIPTION
    Runs the zizmor CLI against GitHub Actions workflow files to detect security
    issues such as expression injection, untrusted input, and unpinned actions.
    If zizmor is not installed, writes a warning and returns an empty result.
    Never throws -- designed for graceful degradation in the orchestrator.
 
    JSON output is written to a temp file (--output) to avoid stderr/stdout
    mixing. The temp file is cleaned up in a finally block.
.PARAMETER RepoPath
    Path to the repository root to scan. Required.
    Legacy alias: -Repository.
.PARAMETER WorkflowPath
    Relative path to the workflows directory. Defaults to .github/workflows.
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param (
    [Alias('Repository')]
    [string] $RepoPath,

    [string] $WorkflowPath = '.github/workflows',

    [string] $RemoteUrl,

    # Incremental hint (#94). When non-null, the wrapper reports RunMode=Incremental
    # in its result envelope so the orchestrator state layer can record accurate
    # per-tool run modes. Zizmor itself scans static workflow YAML, so the
    # timestamp does not narrow the scan -- but the hint still flows through
    # so reports and state correctly reflect incremental coverage.
    [Nullable[datetime]] $Since
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Incremental run-mode tag (#94). Orchestrator uses this to distinguish genuine
# incremental coverage from a FullFallback when -Since is not supplied.
$effectiveRunMode = if ($null -ne $Since) { 'Incremental' } else { 'Full' }

# Dot-source shared modules for Remove-Credentials, Invoke-WithRetry, Invoke-RemoteRepoClone
$sharedDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'modules' 'shared'
if (-not $sharedDir -or -not (Test-Path $sharedDir)) {
    $sharedDir = Join-Path $PSScriptRoot 'shared'
}
$sanitizePath = Join-Path $sharedDir 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$missingToolPath = Join-Path $sharedDir 'MissingTool.ps1'
if (Test-Path $missingToolPath) { . $missingToolPath }
$retryPath = Join-Path $sharedDir 'Retry.ps1'
if (Test-Path $retryPath) { . $retryPath }
$remoteClonePath = Join-Path $sharedDir 'RemoteClone.ps1'
if (Test-Path $remoteClonePath) { . $remoteClonePath }
# Bootstrap Invoke-WithTimeout for CLI timeout protection
$cliTimeoutPath = Join-Path $sharedDir 'CliTimeout.ps1'
if (Test-Path $cliTimeoutPath) { . $cliTimeoutPath }


$envelopePath = Join-Path $sharedDir '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-ZizmorInstalled {
    $null -ne (Get-Command zizmor -ErrorAction SilentlyContinue)
}

function Get-ZizmorToolVersion {
    try {
        $versionOutput = & zizmor --version 2>$null
        if (-not $versionOutput) { return '' }
        $line = [string](@($versionOutput) | Select-Object -First 1)
        return (Remove-Credentials $line).Trim()
    } catch {
        return ''
    }
}

function Get-ZizmorRepoCoordinates {
    param(
        [string] $RepositoryPath,
        [string] $RemoteUrl
    )

    $remote = ''
    if ($RemoteUrl) {
        $remote = [string]$RemoteUrl
    } elseif ($RepositoryPath -and (Get-Command git -ErrorAction SilentlyContinue)) {
        try {
            $remote = [string](& git -C $RepositoryPath remote get-url origin 2>$null)
        } catch { $remote = '' }
    }

    $owner = ''
    $repo = ''
    if ($remote) {
        $trimmed = $remote.Trim()
        if ($trimmed -match 'github\.com[:/](?<owner>[^/]+)/(?<repo>[^/#?]+?)(?:\.git)?(?:[#?].*)?$') {
            $owner = $Matches['owner']
            $repo = $Matches['repo']
        }
    }

    $sha = ''
    if ($RepositoryPath -and (Get-Command git -ErrorAction SilentlyContinue)) {
        try {
            $sha = [string](& git -C $RepositoryPath rev-parse HEAD 2>$null)
            $sha = $sha.Trim()
        } catch { $sha = '' }
    }

    return @{
        Owner = $owner
        Repo  = $repo
        Sha   = $sha
    }
}

function Resolve-ZizmorPrimaryLocation {
    param([object] $Item)

    $location = @{
        Path      = ''
        StartLine = 0
        EndLine   = 0
    }

    $primary = $null
    if ($Item.PSObject.Properties['locations'] -and $Item.locations) {
        $locs = @($Item.locations)
        if ($locs.Count -gt 0) {
            $primary = $locs[0]
        }
    }
    if (-not $primary -and $Item.PSObject.Properties['location'] -and $Item.location) {
        $primary = $Item.location
    }
    if (-not $primary) { return $location }

    $pathCandidates = @(
        $(if ($primary.PSObject.Properties['symbolic'] -and $primary.symbolic -and $primary.symbolic.PSObject.Properties['key']) { [string]$primary.symbolic.key } else { '' }),
        $(if ($primary.PSObject.Properties['path']) { [string]$primary.path } else { '' }),
        $(if ($primary.PSObject.Properties['file']) { [string]$primary.file } else { '' })
    )
    foreach ($candidate in $pathCandidates) {
        if (-not [string]::IsNullOrWhiteSpace($candidate)) {
            $location.Path = $candidate
            break
        }
    }

    $startCandidates = @(
        $(if ($primary.PSObject.Properties['line']) { [int]$primary.line } else { 0 }),
        $(if ($primary.PSObject.Properties['start_line']) { [int]$primary.start_line } else { 0 }),
        $(if ($primary.PSObject.Properties['line_start']) { [int]$primary.line_start } else { 0 }),
        $(if ($primary.PSObject.Properties['range'] -and $primary.range -and $primary.range.PSObject.Properties['start'] -and $primary.range.start -and $primary.range.start.PSObject.Properties['line']) { [int]$primary.range.start.line } else { 0 }),
        $(if ($primary.PSObject.Properties['span'] -and $primary.span -and $primary.span.PSObject.Properties['start'] -and $primary.span.start -and $primary.span.start.PSObject.Properties['line']) { [int]$primary.span.start.line } else { 0 })
    )
    foreach ($candidate in $startCandidates) {
        if ($candidate -gt 0) {
            $location.StartLine = $candidate
            break
        }
    }

    $endCandidates = @(
        $(if ($primary.PSObject.Properties['end_line']) { [int]$primary.end_line } else { 0 }),
        $(if ($primary.PSObject.Properties['line_end']) { [int]$primary.line_end } else { 0 }),
        $(if ($primary.PSObject.Properties['range'] -and $primary.range -and $primary.range.PSObject.Properties['end'] -and $primary.range.end -and $primary.range.end.PSObject.Properties['line']) { [int]$primary.range.end.line } else { 0 }),
        $(if ($primary.PSObject.Properties['span'] -and $primary.span -and $primary.span.PSObject.Properties['end'] -and $primary.span.end -and $primary.span.end.PSObject.Properties['line']) { [int]$primary.span.end.line } else { 0 })
    )
    foreach ($candidate in $endCandidates) {
        if ($candidate -gt 0) {
            $location.EndLine = $candidate
            break
        }
    }
    if ($location.EndLine -le 0 -and $location.StartLine -gt 0) {
        $location.EndLine = $location.StartLine
    }

    return $location
}

function Get-ZizmorRemediationSnippets {
    param([string] $RuleId)

    $rule = ([string]$RuleId).ToLowerInvariant()
    switch ($rule) {
        'template-injection' {
            return @(@{
                    language = 'yaml'
                    before   = @(
                        'steps:'
                        ' - run: echo "${{ github.event.pull_request.title }}"'
                    ) -join "`n"
                    after    = @(
                        'steps:'
                        ' - env:'
                        ' PR_TITLE: ${{ github.event.pull_request.title }}'
                        ' run: echo "$PR_TITLE"'
                    ) -join "`n"
                })
        }
        'unpinned-uses' {
            return @(@{
                    language = 'yaml'
                    before   = 'uses: actions/checkout@v4'
                    after    = 'uses: actions/checkout@<full-40-char-sha> # v4'
                })
        }
        'dangerous-triggers' {
            return @(@{
                    language = 'yaml'
                    before   = @(
                        'on:'
                        ' pull_request_target:'
                    ) -join "`n"
                    after    = @(
                        'on:'
                        ' pull_request:'
                        'jobs:'
                        ' secure-job:'
                        ' if: github.event.pull_request.head.repo.fork == false'
                    ) -join "`n"
                })
        }
        default { return @() }
    }
}

if (-not (Test-ZizmorInstalled)) {
    Write-MissingToolNotice -Tool 'zizmor' -Message "zizmor is not installed. Skipping zizmor scan. Install from https://github.com/woodruffw/zizmor/releases or: pip install zizmor"
    return [PSCustomObject]@{
        Source   = 'zizmor'
        SchemaVersion = '1.0'
        Status   = 'Skipped'
        Message  = 'zizmor CLI not installed. Install from https://github.com/woodruffw/zizmor/releases or: pip install zizmor'
        Findings = @()
        Errors   = @()
        RunMode  = 'Full'
    }
}

# Remote-first: if -RemoteUrl provided, clone it and scan the clone path.
# Otherwise fall back to local -RepoPath.
$cloneInfo = $null
$cleanupClone = $null
try {
    if ($RemoteUrl) {
        if (-not (Get-Command Invoke-RemoteRepoClone -ErrorAction SilentlyContinue)) {
            Write-Warning "RemoteClone helper not loaded; cannot scan remote URL."
            return [PSCustomObject]@{
                Source = 'zizmor'
                SchemaVersion = '1.0'; Status = 'Failed'
                Message = 'RemoteClone helper unavailable'; Findings = @()
                Errors   = @()
                RunMode = $effectiveRunMode
            }
        }
        $cloneInfo = Invoke-RemoteRepoClone -RepoUrl $RemoteUrl
        if (-not $cloneInfo) {
            return [PSCustomObject]@{
                Source = 'zizmor'
                SchemaVersion = '1.0'; Status = 'Failed'
                Message = "Remote clone failed or host not on allow-list: $RemoteUrl"
                Findings = @()
                Errors   = @()
                RunMode = $effectiveRunMode
            }
        }
        $cleanupClone = $cloneInfo.Cleanup
        $RepoPath = $cloneInfo.Path
    }

    if (-not $RepoPath) {
        return [PSCustomObject]@{
            Source = 'zizmor'
            SchemaVersion = '1.0'; Status = 'Skipped'
            Message = 'No -RemoteUrl or -RepoPath provided'; Findings = @()
            Errors   = @()
            RunMode = $effectiveRunMode
        }
    }
    $scanPath = Join-Path $RepoPath $WorkflowPath
    if (-not (Test-Path $scanPath)) {
        Write-Warning "Workflow path not found: $scanPath"
        return [PSCustomObject]@{
            Source   = 'zizmor'
            SchemaVersion = '1.0'
            Status   = 'Skipped'
            Message  = "Workflow path not found: $scanPath"
            Findings = @()
            Errors   = @()
            RunMode  = $effectiveRunMode
        }
    }

    Write-Verbose "Running zizmor for workflow path $scanPath"
    $repoCoordinates = Get-ZizmorRepoCoordinates -RepositoryPath $RepoPath -RemoteUrl $RemoteUrl
    $toolVersion = Get-ZizmorToolVersion

    # zizmor 1.x always writes JSON to stdout (the legacy --output flag was removed,
    # which caused exit code 2 = clap argument parsing failure, see #768). Capture
    # stdout via PowerShell redirection to a temp file. --no-exit-codes prevents
    # finding-severity exit codes (11..14) from being misread as hard failures.
    $reportFile = Join-Path ([System.IO.Path]::GetTempPath()) "zizmor-report-$([guid]::NewGuid().ToString('N')).json"
    $stderrFile = "$reportFile.err"

    try {
        $invokeZizmorScan = {
            $script:zizmorExec = Invoke-WithTimeout -Command 'zizmor' -Arguments @('--format=json', '--no-exit-codes', $scanPath) -TimeoutSec 300
            if ([int]$script:zizmorExec.ExitCode -eq -1) {
                throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:zizmor' -Category 'TimeoutExceeded' -Reason 'zizmor timed out after 300 seconds.' -Remediation 'Check repository size or increase timeout.' -Details ''))
            }
            # Write stdout (JSON) to reportFile; stderr to stderrFile
            if ($script:zizmorExec.PSObject.Properties['Stdout'] -and $script:zizmorExec.Stdout) {
                $script:zizmorExec.Stdout | Set-Content -Path $reportFile -Encoding UTF8
            } elseif ($script:zizmorExec.Output) {
                $script:zizmorExec.Output | Set-Content -Path $reportFile -Encoding UTF8
            }
            if ($script:zizmorExec.PSObject.Properties['Stderr'] -and $script:zizmorExec.Stderr) {
                $script:zizmorExec.Stderr | Set-Content -Path $stderrFile -Encoding UTF8
            }
        }
        $useRetry = Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue
        if ($useRetry) {
            Invoke-WithRetry -ScriptBlock $invokeZizmorScan
        } else {
            & $invokeZizmorScan
        }

        $exitCode = [int]$script:zizmorExec.ExitCode

        $stderrText = ''
        if (Test-Path $stderrFile) {
            $stderrText = (Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue) ?? ''
            if ($stderrText) { Write-Verbose "zizmor stderr: $stderrText" }
        }

        $reportExists = Test-Path $reportFile
        $reportSize = if ($reportExists) { (Get-Item $reportFile).Length } else { 0 }

        # Non-zero exit with no report content = hard failure.
        if ($exitCode -ne 0 -and $reportSize -le 0) {
            $sanitizedErr = Remove-Credentials ([string]$stderrText).Trim()
            $msg = "zizmor exited with code $exitCode and produced no report"
            if ($sanitizedErr) { $msg = "$msg`: $sanitizedErr" }
            Write-Warning (Remove-Credentials $msg)
            return [PSCustomObject]@{
                Source   = 'zizmor'
                SchemaVersion = '1.0'
                Status   = 'Failed'
                Message  = Remove-Credentials $msg
                Findings = @()
                Errors   = @()
                RunMode  = $effectiveRunMode
            }
        }

        $json = $null
        if ($reportExists -and $reportSize -gt 0) {
            $jsonText = Get-Content $reportFile -Raw -ErrorAction SilentlyContinue
            if ($jsonText -and $jsonText.Trim()) {
                try {
                    $json = $jsonText | ConvertFrom-Json -ErrorAction Stop
                } catch {
                    Write-Warning (Remove-Credentials "zizmor report JSON parse failed: $_")
                    return [PSCustomObject]@{
                        Source   = 'zizmor'
                        SchemaVersion = '1.0'
                        Status   = 'Failed'
                        Message  = Remove-Credentials "Report JSON parse failed: $_"
                        Findings = @()
                        Errors   = @()
                        RunMode  = $effectiveRunMode
                    }
                }
            } else {
                $json = @()
            }
        } else {
            # exit 0 with empty/no stdout — zizmor found nothing
            $json = @()
        }
    } finally {
        Remove-Item $reportFile -Force -ErrorAction SilentlyContinue
        Remove-Item $stderrFile -Force -ErrorAction SilentlyContinue
    }

    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()

    # zizmor JSON output is an array of finding objects
    $items = if ($json -is [System.Collections.IEnumerable] -and $json -isnot [string]) {
        @($json)
    } elseif ($null -ne $json) {
        @($json)
    } else {
        @()
    }

    foreach ($item in $items) {
        $ruleId = ''
        if ($item.PSObject.Properties['id'] -and $item.id) {
            $ruleId = [string]$item.id
        }

        $desc = ''
        if ($item.PSObject.Properties['desc'] -and $item.desc) {
            $desc = [string]$item.desc
        }

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

        $learnMoreUrl = ''
        if ($item.PSObject.Properties['url'] -and $item.url) {
            $learnMoreUrl = [string]$item.url
        }

        $locInfo = Resolve-ZizmorPrimaryLocation -Item $item
        $filePath = [string]$locInfo.Path
        if (-not $filePath) {
            $filePath = $WorkflowPath
        }
        $filePath = $filePath -replace '\\', '/'
        $filePath = $filePath -replace '^\./', ''

        $severityTier = $severity.ToLowerInvariant()
        $impact = $severity
        $effort = if ($ruleId -eq 'unpinned-uses') { 'Medium' } else { 'Low' }

        $docsUrl = ''
        if ($ruleId) {
            $docsUrl = "https://docs.zizmor.sh/audits/#$ruleId"
        }

        $startLine = [int]$locInfo.StartLine
        $endLine = [int]$locInfo.EndLine
        $lineFragment = ''
        if ($startLine -gt 0) {
            if ($endLine -gt $startLine) {
                $lineFragment = "#L$startLine-L$endLine"
            } else {
                $lineFragment = "#L$startLine"
            }
        }

        $evidenceUris = @()
        if ($repoCoordinates.Owner -and $repoCoordinates.Repo -and $repoCoordinates.Sha -and $filePath) {
            $blobPath = $filePath
            if ($blobPath.StartsWith('/')) { $blobPath = $blobPath.TrimStart('/') }
            $evidenceUris = @("https://github.com/$($repoCoordinates.Owner)/$($repoCoordinates.Repo)/blob/$($repoCoordinates.Sha)/$blobPath$lineFragment")
        }

        $deepLinkUrl = if ($docsUrl) { $docsUrl } elseif (@($evidenceUris).Count -gt 0) { $evidenceUris[0] } else { '' }
        if (-not $learnMoreUrl) { $learnMoreUrl = $deepLinkUrl }

        $baselineTags = @()
        if ($ruleId) { $baselineTags += $ruleId }
        $baselineTags += "severity:$severityTier"

        $mitreTechniques = @()
        switch (($ruleId ?? '').ToLowerInvariant()) {
            'template-injection' { $mitreTechniques = @('T1059') }
            'expression-injection' { $mitreTechniques = @('T1059') }
            'unpinned-uses' { $mitreTechniques = @('T1195.001') }
        }

        $entityRefs = @()
        if ($repoCoordinates.Owner -and $repoCoordinates.Repo -and $filePath) {
            $entityRefs = @("$($repoCoordinates.Owner)/$($repoCoordinates.Repo)/$filePath")
        }

        $remediationSnippets = Get-ZizmorRemediationSnippets -RuleId $ruleId

        $title = if ($ruleId -and $desc) {
            "$ruleId`: $desc"
        } elseif ($ruleId) {
            $ruleId
        } elseif ($desc) {
            $desc
        } else {
            'Unknown zizmor finding'
        }

        $detail = Remove-Credentials $desc
        if ($filePath) {
            $detail = "$detail (file: $filePath)"
        }

        $findings.Add([PSCustomObject]@{
            Id           = [guid]::NewGuid().ToString()
            Category     = 'CI/CD Security'
            RuleId       = $ruleId
            Title        = $title
            Severity     = $severity
            Compliant    = $false
            Detail       = $detail
            Remediation  = ''
            ResourceId   = $filePath
            LearnMoreUrl = $learnMoreUrl
            Pillar       = 'Security'
            Impact       = $impact
            Effort       = $effort
            DeepLinkUrl  = $deepLinkUrl
            RemediationSnippets = @($remediationSnippets)
            EvidenceUris = @($evidenceUris)
            BaselineTags = @($baselineTags)
            MitreTechniques = @($mitreTechniques)
            EntityRefs   = @($entityRefs)
            ToolVersion  = $toolVersion
            StartLine    = $startLine
            EndLine      = $endLine
        })
    }

    return [PSCustomObject]@{
        Source   = 'zizmor'
        SchemaVersion = '1.0'
        Status   = 'Success'
        Message  = ''
        Findings = @($findings)
        Errors   = @()
        ToolVersion = $toolVersion
        RunMode  = $effectiveRunMode
        SinceUtc = if ($null -ne $Since) { ([datetime]$Since).ToUniversalTime().ToString('o') } else { $null }
    }
} catch {
    Write-Warning (Remove-Credentials "zizmor scan failed: $_")
    return [PSCustomObject]@{
        Source   = 'zizmor'
        SchemaVersion = '1.0'
        Status   = 'Failed'
        Message  = Remove-Credentials "$_"
        Findings = @()
        Errors   = @()
        RunMode  = $effectiveRunMode
    }
} finally {
    if ($cleanupClone) {
        try { & $cleanupClone } catch { Write-Verbose "zizmor clone cleanup failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))" }
    }
}