Private/AuditPipeline.ps1

# Audit pipeline stages shared by the orchestrator's code paths.
#
# The report/summary tail (dedupe -> suppressions -> level filter -> score ->
# console summary -> HTML report -> history -> exit code) used to exist twice in
# Public\Invoke-CISAzureAudit.ps1 — once for the full run and once for -ReportOnly —
# and the two copies had already drifted in small ways. Both paths now call
# Complete-AuditRun.

function New-AuditRunSummary {
    <#
    .SYNOPSIS
    Uniform result contract for every orchestrator code path (setup failure,
    ReportOnly, full run).
    #>

    param(
        [int]$Code,
        [string]$Reason = "",
        [hashtable]$Counts = $null,
        $Score = $null,
        [string]$ReportPath = "",
        [AllowEmptyCollection()][object[]]$Results = @(),
        [int]$SubscriptionCount = 0,
        [string]$Elapsed = ""
    )
    [PSCustomObject]@{
        PSTypeName        = 'CISAzureFoundationsBenchmark.AuditSummary'
        ExitCode          = $Code
        Reason            = $Reason
        Counts            = $Counts
        Score             = $Score
        ReportPath        = $ReportPath
        Results           = $Results
        SubscriptionCount = $SubscriptionCount
        Elapsed           = $Elapsed
    }
}

function Complete-AuditRun {
    <#
    .SYNOPSIS
    Final pipeline stage: turn raw results into the report and the typed summary.

    .DESCRIPTION
    Deduplicates, applies suppressions and the level filter, computes the score,
    prints the console summary, writes the HTML/JSON/CSV/SARIF report, optionally
    appends the run to history, opens the report, and returns the AuditSummary.
    Used by both the full-run and -ReportOnly paths of Invoke-CISAzureAudit.
    #>

    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Results,
        [Parameter(Mandatory)][string]$OutputPath,
        [Parameter(Mandatory)][string]$SuppressionsFile,
        [Parameter(Mandatory)][string]$Level,
        [Parameter(Mandatory)][string]$ScopeLabel,
        [Parameter(Mandatory)][string]$Tenant,
        [Parameter(Mandatory)][string]$CallerName,
        [Parameter(Mandatory)][string]$CallerType,
        [AllowEmptyCollection()][string[]]$SubscriptionNames = @(),
        [hashtable]$SubTimestamps = @{},
        [int]$SubscriptionCount = 0,
        [switch]$AppendHistory,
        [AllowEmptyCollection()][string[]]$HistorySubscriptionIds = @(),
        [string]$ElapsedLabel = "",
        [string]$CompareWith = "",
        [switch]$SetExitCode,
        [switch]$NoOpen
    )

    # ── Dedupe, suppressions, level filter ────────────────────────────────────
    $suppressions = @(Get-Suppressions -Path $SuppressionsFile)
    $finalResults = Remove-DuplicateResults -Results $Results
    $finalResults = @(Invoke-Suppressions -Results $finalResults -Suppressions $suppressions)

    if ($Level -eq "1") { $finalResults = @($finalResults | Where-Object { $_.Level -eq 1 }) }
    if ($Level -eq "2") { $finalResults = @($finalResults | Where-Object { $_.Level -eq 2 }) }

    # ── Score & console summary ───────────────────────────────────────────────
    $counts   = Get-AuditCounts -Results $finalResults
    $assessed = Get-AssessedCount -Counts $counts
    $score    = Get-AuditScore -Counts $counts

    $completeLine = " COMPLETE — {0} checks | {1} subscription(s)" -f $finalResults.Count, $SubscriptionCount
    if ($ElapsedLabel) { $completeLine += " | `u{23F1} $ElapsedLabel" }

    Write-Host ""
    Write-Host (" " + "`u{2501}" * 60) -ForegroundColor DarkGray
    Write-Host $completeLine -ForegroundColor White
    # Invariant culture so the console score matches the reports ("62.1", never "62,1")
    # regardless of the machine locale; -f formats numbers with the current culture.
    $scoreText = $score.ToString([System.Globalization.CultureInfo]::InvariantCulture)
    Write-Host (" Compliance Score : {0}% ({1} of {2} assessed controls; ERROR counts as failing, excludes INFO/MANUAL/SUPPRESSED)" -f $scoreText, $counts.PASS, $assessed) -ForegroundColor $(if ($score -ge 80) { "Green" } elseif ($score -ge 60) { "Yellow" } else { "Red" })
    Write-Host ("`u{2705} PASS {0,4}" -f $counts.PASS)       -ForegroundColor Green
    Write-Host ("`u{274C} FAIL {0,4}" -f $counts.FAIL)       -ForegroundColor Red
    Write-Host ("`u{26A0}`u{FE0F} ERROR {0,4}" -f $counts.ERROR)      -ForegroundColor DarkYellow
    Write-Host ("`u{2139}`u{FE0F} INFO {0,4}" -f $counts.INFO)       -ForegroundColor Blue
    Write-Host ("`u{1F4CB} MANUAL {0,4}" -f $counts.MANUAL)     -ForegroundColor DarkMagenta
    Write-Host ("`u{1F507} SUPPRESSED {0,4}" -f $counts.SUPPRESSED) -ForegroundColor DarkGray
    Write-Host (" " + "`u{2501}" * 60) -ForegroundColor DarkGray
    Write-Host ""

    # ── Run-to-run diff (optional) ────────────────────────────────────────────
    $diff = $null
    if ($CompareWith) {
        $baselinePath = if ($CompareWith -eq 'auto') { Find-PreviousReportJson -OutputPath $OutputPath } else { $CompareWith }
        if ($baselinePath -and (Test-Path $baselinePath)) {
            try {
                $prev = @(Get-Content $baselinePath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 5)
                $diff = Get-RunDiff -CurrentResults $finalResults -PreviousResults $prev
                $diffPath = [System.IO.Path]::ChangeExtension($OutputPath, '.diff.json')
                Write-RunDiffJson -Diff $diff -BaselinePath $baselinePath -OutputPath $diffPath
                Write-Host (" Diff vs {0}: {1} regression(s), {2} improvement(s), {3} new, {4} removed `u{2192} {5}" -f `
                    (Split-Path $baselinePath -Leaf), $diff.Regressions.Count, $diff.Improvements.Count, `
                    $diff.New.Count, $diff.Removed.Count, (Split-Path $diffPath -Leaf)) -ForegroundColor Cyan
            } catch {
                Write-AuditLog "Could not diff against '$baselinePath': $_" -Level WARNING
                $diff = $null
            }
        } else {
            Write-AuditLog "CompareWith baseline not found ($CompareWith) — skipping run diff." -Level WARNING
        }
    }

    # ── Report + history ──────────────────────────────────────────────────────
    $scopeInfo = @{
        tenant        = $Tenant
        user          = $CallerName
        caller_type   = $CallerType
        scope_label   = $ScopeLabel
        subscriptions = @($SubscriptionNames)
        level_filter  = $Level
    }

    $historyPath = Get-HistoryPathFor -OutputPath $OutputPath
    $history     = @(Get-AuditHistory -HistoryPath $historyPath)

    New-CISHtmlReport -Results $finalResults -OutputPath $OutputPath -ScopeLabel $ScopeLabel -History $history -ScopeInfo $scopeInfo -SubTimestamps $SubTimestamps -Diff $diff

    if ($AppendHistory) {
        Add-AuditHistoryEntry -HistoryPath $historyPath -Results $finalResults -SubscriptionIds $HistorySubscriptionIds
    }

    Write-Host " Report: $([System.IO.Path]::GetFullPath($OutputPath))" -ForegroundColor Cyan
    Write-Host ""
    if (-not $NoOpen) {
        try { Invoke-Item ([System.IO.Path]::GetFullPath($OutputPath)) } catch { $null = $_ }
    }

    # ── Exit code & summary ───────────────────────────────────────────────────
    $code = if ($SetExitCode -and ($counts.FAIL + $counts.ERROR) -gt 0) { 2 } else { 0 }
    New-AuditRunSummary -Code $code -Counts $counts -Score $score `
        -ReportPath ([System.IO.Path]::GetFullPath($OutputPath)) -Results $finalResults `
        -SubscriptionCount $SubscriptionCount -Elapsed $ElapsedLabel
}