Payload/scripts/security-pre-push.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Polyglot security gate for Python and PowerShell repos (Tier A / B + Trivy + deps).

.DESCRIPTION
    Windows-first alternative to scripts/security-runner.mjs for non-Node repos.
    Copy to <repo>/scripts/security-pre-push.ps1 and customize stack paths below.

.PARAMETER Stack
    python - xml-migrator style (Semgrep p/python, pip-audit)
    powershell - utilities / hook repos (Semgrep powershell-security.yml, Trivy)

.PARAMETER Mode
    code - Tier A Semgrep on application paths only
    parity - Tier B Semgrep (registry auto) on full repo
    secrets - Trivy secret/misconfig (blocking)
    deps - pip-audit (python only)
    pre-push - Tier A + deps + secrets (fast push gate)
    pr - pre-push + parity (pre-PR gate)

.EXAMPLE
    .\scripts\security-pre-push.ps1 -Stack python -Mode pre-push

.EXAMPLE
    .\scripts\security-pre-push.ps1 -Stack powershell -Mode pr
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateSet('python', 'powershell')]
    [string]$Stack = 'python',

    [Parameter()]
    [ValidateSet('code', 'parity', 'secrets', 'deps', 'pre-push', 'pr')]
    [string]$Mode = 'pre-push'
)

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

# --- Adapt per repo ---------------------------------------------------------
$PythonSemgrepTargets = @('xml_extractor', 'run_migrator.py')
$PowerShellSemgrepConfig = 'powershell-security.semgrep.yml'
# Trivy --skip-dirs matches doublestar glob patterns; a bare name (e.g. "logs")
# only matches at the scan root and silently fails to skip nested occurrences
# (e.g. some-project/logs), which can turn a multi-second scan into a
# multi-minute one on repos with nested log/output/build directories.
$TrivySkipDirs = '**/.git,**/node_modules,**/logs,**/.venv,**/__pycache__'
# Registry-based Semgrep configs (auto or p/... packs) depend on a network
# rule-fetch that can stall indefinitely ("Loading rules from registry").
# Parity mode bounds this with a hard timeout and fails open (warns, does not
# throw) rather than hanging a pre-PR gate forever.
$SemgrepParityTimeoutMs = 120000
# ---------------------------------------------------------------------------

$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
Push-Location $repoRoot
try {
    function Get-TrivyCommand {
        if ($env:LOCALAPPDATA) {
            $wingetTrivy = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\AquaSecurity.Trivy_Microsoft.Winget.Source_8wekyb3d8bbwe\trivy.exe'
            if (Test-Path $wingetTrivy) {
                return $wingetTrivy
            }
        }
        return 'trivy'
    }

    function Invoke-External {
        param(
            [Parameter(Mandatory = $true)][string]$Name,
            [Parameter(Mandatory = $true)][string]$Exe,
            [Parameter(Mandatory = $true)][string[]]$Args
        )
        Write-Output "[security-pre-push] $Name"
        Write-Output "[security-pre-push] $Exe $($Args -join ' ')"
        & $Exe @Args
        if ($LASTEXITCODE -ne 0) {
            throw "$Name failed with exit code $LASTEXITCODE"
        }
    }

    function Invoke-SemgrepCode {
        if ($Stack -eq 'python') {
            $missing = @($PythonSemgrepTargets | Where-Object { -not (Test-Path $_) })
            if ($missing.Count -gt 0) {
                throw "Python Semgrep targets not found: $($missing -join ', ')"
            }
            Invoke-External -Name 'Semgrep Tier A (p/python)' -Exe 'semgrep' -Args (
                @('scan', '--config', 'p/python', '--error') + $PythonSemgrepTargets
            )
            return
        }

        if (-not (Test-Path $PowerShellSemgrepConfig)) {
            throw "PowerShell Semgrep config not found: $PowerShellSemgrepConfig"
        }
        Invoke-External -Name 'Semgrep Tier A (powershell-security)' -Exe 'semgrep' -Args @(
            'scan', '--config', $PowerShellSemgrepConfig, '--error', '.'
        )
    }

    function Invoke-SemgrepParity {
        $semgrepCmd = Get-Command semgrep -ErrorAction SilentlyContinue
        if (-not $semgrepCmd) {
            throw 'semgrep not found on PATH.'
        }

        Write-Output '[security-pre-push] Semgrep Tier B (parity)'
        $semgrepArgs = @('scan', '--config', 'auto', '--metrics', 'on', '--error', '.')
        $semgrepProcess = Start-Process -FilePath $semgrepCmd.Source -ArgumentList $semgrepArgs -NoNewWindow -PassThru
        $semgrepExited = $semgrepProcess.WaitForExit($SemgrepParityTimeoutMs)

        if (-not $semgrepExited) {
            Stop-Process -Id $semgrepProcess.Id -Force -ErrorAction SilentlyContinue
            Write-Warning "[security-pre-push] Semgrep parity scan did not finish within $($SemgrepParityTimeoutMs / 1000)s; skipping. Run 'semgrep scan --config auto .' manually when convenient."
            return
        }

        if ($semgrepProcess.ExitCode -ne 0) {
            throw "Semgrep Tier B (parity) failed with exit code $($semgrepProcess.ExitCode)"
        }
    }

    function Invoke-TrivySecret {
        $trivy = Get-TrivyCommand
        Invoke-External -Name 'Trivy secrets/misconfig' -Exe $trivy -Args @(
            'fs',
            '--scanners', 'secret,misconfig',
            '--severity', 'HIGH,CRITICAL',
            '--skip-dirs', $TrivySkipDirs,
            '--exit-code', '1',
            '.'
        )
    }

    function Invoke-PythonDependency {
        if (-not (Test-Path 'requirements.txt')) {
            Write-Output '[security-pre-push] skip pip-audit (no requirements.txt)'
            return
        }
        $pipAudit = Get-Command pip-audit -ErrorAction SilentlyContinue
        if (-not $pipAudit) {
            throw 'pip-audit not found. Install with: pip install pip-audit'
        }
        Invoke-External -Name 'pip-audit' -Exe 'pip-audit' -Args @('-r', 'requirements.txt')
    }

    $steps = switch ($Mode) {
        'code' { @('semgrepCode') }
        'parity' { @('semgrepParity') }
        'secrets' { @('trivySecrets') }
        'deps' { @('pythonDeps') }
        'pre-push' { @('semgrepCode', 'pythonDeps', 'trivySecrets') }
        'pr' { @('semgrepCode', 'pythonDeps', 'trivySecrets', 'semgrepParity') }
    }

    if ($Stack -eq 'powershell') {
        $steps = $steps | Where-Object { $_ -ne 'pythonDeps' }
    }

    foreach ($step in $steps) {
        switch ($step) {
            'semgrepCode' { Invoke-SemgrepCode }
            'semgrepParity' { Invoke-SemgrepParity }
            'trivySecrets' { Invoke-TrivySecret }
            'pythonDeps' { Invoke-PythonDependency }
            default { throw "Unknown step: $step" }
        }
    }

    Write-Output "[security-pre-push] All requested gates passed (stack=$Stack mode=$Mode)."
}
finally {
    Pop-Location
}