internal/Audit/New-GuerrillaRunRecord.ps1

# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution

function Get-GuerrillaEvidenceHash {
    <#
    .SYNOPSIS
        Deterministic SHA256 over a finding's evidence (CurrentValue + Details).
    .DESCRIPTION
        The run history stores verdicts and evidence hashes, never raw evidence
        values, so the history file cannot leak what the report contains. The
        hash is deterministic: Details keys are sorted before serialization so
        the same evidence always produces the same hash across runs.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][string]$CurrentValue,
        [AllowNull()]$Details
    )
    $detailText = ''
    if ($null -ne $Details) {
        try {
            if ($Details -is [System.Collections.IDictionary]) {
                $ordered = [ordered]@{}
                foreach ($k in ($Details.Keys | Sort-Object { "$_" })) { $ordered["$k"] = $Details[$k] }
                $detailText = $ordered | ConvertTo-Json -Depth 6 -Compress
            } else {
                $detailText = $Details | ConvertTo-Json -Depth 6 -Compress
            }
        } catch {
            $detailText = "$Details"
        }
    }
    $bytes = [System.Text.Encoding]::UTF8.GetBytes("$CurrentValue`n$detailText")
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        return ([System.BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant()
    } finally {
        $sha.Dispose()
    }
}

function Get-GuerrillaTargetHash {
    <#
    .SYNOPSIS
        Privacy-preserving identity for the assessed target(s).
    .DESCRIPTION
        A SHA256 over the normalized (lowercased, sorted) target identifiers so
        run history can refuse to compare different tenants/domains without
        storing the tenant or domain name itself.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string[]]$TargetId)
    $normalized = ($TargetId | ForEach-Object { "$_".Trim().ToLowerInvariant() } | Sort-Object) -join '|'
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
        return ([System.BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant()
    } finally {
        $sha.Dispose()
    }
}

function ConvertTo-GuerrillaRunVerdict {
    <#
    .SYNOPSIS
        Normalize a finding Status to the four run-record verdicts.
    .DESCRIPTION
        PASS/FAIL/WARN pass through; SKIP and ERROR are both "Not Assessed"
        (absence of evidence is never a best case). Anything else throws:
        an unknown status silently mapped is a silent diff waiting to happen.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Status)
    switch ($Status.ToUpperInvariant()) {
        'PASS'  { return 'PASS' }
        'FAIL'  { return 'FAIL' }
        'WARN'  { return 'WARN' }
        'SKIP'  { return 'Not Assessed' }
        'ERROR' { return 'Not Assessed' }
        default { throw "Unknown finding status '$Status': refusing to guess a run-record verdict." }
    }
}

function New-GuerrillaRunRecord {
    <#
    .SYNOPSIS
        Build the run record persisted after a completed assessment.
    .DESCRIPTION
        Pure builder: findings in, record out; writes nothing. The record is
        small enough to keep forever (verdicts, hashes, and scores; no raw
        evidence) and carries everything Compare-GuerrillaRun needs: per-check
        verdict + evidence hash, module version, platforms, overall and
        per-Zero-Trust-pillar scores, and the Not Assessed count.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][PSCustomObject[]]$Findings,
        [Parameter(Mandatory)][string[]]$Platforms,
        [Parameter(Mandatory)][string[]]$TargetId,
        [Parameter(Mandatory)][string]$ScanId,
        [AllowNull()][Nullable[int]]$OverallScore
    )

    $moduleVersion = "$($ExecutionContext.SessionState.Module.Version)"
    if (-not $moduleVersion -or $moduleVersion -eq '') {
        $moduleVersion = "$((Import-PowerShellDataFile (Join-Path $script:ModuleRoot 'Guerrilla.psd1')).ModuleVersion)"
    }

    $checks = foreach ($f in $Findings) {
        [ordered]@{
            checkId          = "$($f.CheckId)"
            orgUnitPath      = "$($f.OrgUnitPath)"
            verdict          = ConvertTo-GuerrillaRunVerdict -Status "$($f.Status)"
            rawStatus        = "$($f.Status)"
            severity         = "$($f.Severity)"
            category         = "$($f.Category)"
            zeroTrustPillar  = "$($f.ZeroTrustPillar)"
            zeroTrustWeight  = $f.ZeroTrustWeight
            evidenceHash     = Get-GuerrillaEvidenceHash -CurrentValue "$($f.CurrentValue)" -Details $f.Details
        }
    }
    $checks = @($checks)

    # Per-pillar scores from the same engine the report uses.
    $pillarScores = [ordered]@{}
    if ($Findings.Count -gt 0) {
        foreach ($p in ($Findings | Get-ZeroTrustScore)) {
            $pillarScores["$($p.Pillar)"] = $p.ScorePercent
        }
    }

    $verdictGroups = @{}
    foreach ($c in $checks) { $verdictGroups[$c.verdict] = 1 + ($verdictGroups[$c.verdict] ?? 0) }

    [ordered]@{
        schemaVersion    = 1
        tool             = 'Guerrilla'
        moduleVersion    = $moduleVersion
        generatedAt      = [datetime]::UtcNow.ToString('o')
        runId            = "$ScanId"
        scope            = [ordered]@{
            platforms  = @($Platforms | Sort-Object)
            targetHash = Get-GuerrillaTargetHash -TargetId $TargetId
        }
        overallScore     = $OverallScore
        pillarScores     = $pillarScores
        summary          = [ordered]@{
            total       = $checks.Count
            pass        = [int]($verdictGroups['PASS'] ?? 0)
            fail        = [int]($verdictGroups['FAIL'] ?? 0)
            warn        = [int]($verdictGroups['WARN'] ?? 0)
            notAssessed = [int]($verdictGroups['Not Assessed'] ?? 0)
        }
        checks           = $checks
    }
}