Private/Suppressions.ps1

# Suppressions — accepted risk management for audit findings
#
# Loads suppressions.json and applies SUPPRESSED status to matching FAIL/ERROR
# results at report-generation time. Checkpoints are never modified — the raw
# FAIL is always preserved on disk, so removing a suppression and re-running
# -ReportOnly immediately reinstates the finding.
#
# Suppression file format (suppressions.json):
#
# [
# {
# "control_id": "7.1",
# "resource": "jumphost-nsg",
# "subscription": "Production",
# "justification": "Intentional RDP jump host — restricted by Azure Firewall",
# "expires": "2026-12-31"
# }
# ]
#
# Matching rules:
# control_id — required; exact match
# resource — optional; if omitted, matches any resource for that control
# subscription — optional; if omitted, matches across all subscriptions
# Only FAIL and ERROR results can be suppressed
#
# Expiry rules:
# expires is required — prevents suppressions from becoming permanent
# Maximum 1 year from today; anything longer is capped with a warning
# Expired entries are skipped and logged

# Maximum allowed suppression duration in days
$script:MAX_SUPPRESSION_DAYS = 365

function Get-Suppressions {
    <#
    .SYNOPSIS
    Load, validate, and return active suppressions from a JSON file.
    #>

    param([string]$Path = "suppressions.json")

    if (-not (Test-Path $Path)) { return @() }

    try {
        $raw  = Get-Content $Path -Raw -Encoding UTF8
        $data = ConvertFrom-Json -InputObject $raw -Depth 10
    } catch {
        Write-AuditLog "Suppression file '$Path' is not valid JSON: $_" -Level WARNING
        return @()
    }

    # Accept both flat array [{...}] and wrapped { "suppressions": [{...}] }
    # ConvertFrom-Json unwraps single-element arrays, so use @() to re-wrap.
    $entries = if ($data -is [array]) {
        $data
    } elseif ($data.PSObject.Properties['suppressions']) {
        @($data.suppressions)
    } elseif ($data.PSObject.Properties['control_id']) {
        # Single-element flat array was unwrapped by ConvertFrom-Json
        @($data)
    } else {
        @()
    }
    if (-not $entries -or $entries.Count -eq 0) {
        Write-AuditLog "Suppression file '$Path' has no entries." -Level INFO
        return @()
    }

    $today   = [datetime]::Today
    $maxDate = $today.AddDays($script:MAX_SUPPRESSION_DAYS)
    $valid   = [System.Collections.Generic.List[object]]::new()

    for ($i = 0; $i -lt $entries.Count; $i++) {
        $entry = $entries[$i]
        $num   = $i + 1

        # Validate required fields — use PSObject.Properties to avoid
        # PropertyNotFoundException under Set-StrictMode -Version Latest
        $missing = $false
        foreach ($field in @('control_id', 'justification', 'expires')) {
            if (-not $entry.PSObject.Properties[$field] -or -not $entry.$field) {
                Write-AuditLog "Suppression #$num in '$Path' is missing required field '$field' — skipped." -Level WARNING
                $missing = $true
                break
            }
        }
        if ($missing) { continue }

        # Parse expiry date
        try {
            $expires = [datetime]::ParseExact([string]$entry.expires, 'yyyy-MM-dd', $null)
        } catch {
            Write-AuditLog "Suppression #$num (control $($entry.control_id)): invalid expires '$($entry.expires)' — expected YYYY-MM-DD. Skipped." -Level WARNING
            continue
        }

        # Skip expired
        if ($expires -lt $today) {
            $resLabel = if ($entry.PSObject.Properties['resource'] -and $entry.resource) { $entry.resource } else { '*' }
            Write-AuditLog "Suppression #$num (control $($entry.control_id), resource '$resLabel') expired $($expires.ToString('yyyy-MM-dd')) — finding will show as FAIL/ERROR." -Level WARNING
            continue
        }

        # Cap at 1 year
        if ($expires -gt $maxDate) {
            Write-AuditLog "Suppression #$num (control $($entry.control_id)): expiry $($expires.ToString('yyyy-MM-dd')) exceeds 1-year maximum — capped at $($maxDate.ToString('yyyy-MM-dd'))." -Level WARNING
            $expires = $maxDate
        }

        $valid.Add([PSCustomObject]@{
            ControlId     = [string]$entry.control_id
            Resource      = if ($entry.PSObject.Properties['resource'] -and $entry.resource)         { [string]$entry.resource }     else { $null }
            Subscription  = if ($entry.PSObject.Properties['subscription'] -and $entry.subscription) { [string]$entry.subscription } else { $null }
            Justification = [string]$entry.justification
            Expires       = $expires
        })
    }

    Write-AuditLog "`u{1F507} Loaded $($valid.Count) active suppression(s) from $Path" -Level INFO
    return $valid.ToArray()
}

function Invoke-Suppressions {
    <#
    .SYNOPSIS
    Return a new array of results with SUPPRESSED status applied where matched.
    Only FAIL and ERROR results are candidates.
    #>

    param(
        [Parameter(Mandatory)][object[]]$Results,
        [object[]]$Suppressions = @()
    )

    if (-not $Suppressions -or $Suppressions.Count -eq 0) { return $Results }

    $out = [System.Collections.Generic.List[object]]::new()
    foreach ($r in $Results) {
        if ($r.Status -ne $script:FAIL -and $r.Status -ne $script:ERR) {
            $out.Add($r)
            continue
        }

        $match = Find-SuppressionMatch -Result $r -Suppressions $Suppressions
        if ($match) {
            $out.Add([PSCustomObject]@{
                ControlId        = $r.ControlId
                Title            = $r.Title
                Level            = $r.Level
                Section          = $r.Section
                Status           = $script:SUPPRESSED
                Details          = "$($r.Details) [Accepted risk: $($match.Justification) — expires $($match.Expires.ToString('yyyy-MM-dd'))]"
                Remediation      = $r.Remediation
                SubscriptionId   = $r.SubscriptionId
                SubscriptionName = $r.SubscriptionName
                Resource         = $r.Resource
            })
        } else {
            $out.Add($r)
        }
    }

    return $out.ToArray()
}

function Find-SuppressionMatch {
    <#
    .SYNOPSIS
    Return the first suppression that matches a result, or $null.
    #>

    param(
        [Parameter(Mandatory)][object]$Result,
        [Parameter(Mandatory)][object[]]$Suppressions
    )

    foreach ($sup in $Suppressions) {
        if ($Result.ControlId -ne $sup.ControlId) { continue }
        if ($sup.Resource -and $sup.Resource -ine $Result.Resource) { continue }
        if ($sup.Subscription -and $sup.Subscription -ine $Result.SubscriptionName) { continue }
        return $sup
    }
    return $null
}

function Show-Suppressions {
    <#
    .SYNOPSIS
    Print all active suppressions in a readable table.
    #>

    param(
        [object[]]$Suppressions,
        [string]$Path = "suppressions.json"
    )

    if (-not $Suppressions -or $Suppressions.Count -eq 0) {
        Write-Host "No active suppressions in $Path."
        return
    }

    $today = [datetime]::Today
    Write-Host "`nActive suppressions from ${Path}:`n"
    Write-Host (" {0,-10} {1,-25} {2,-20} {3,-14} {4}" -f "Control", "Resource", "Subscription", "Expires", "Justification")
    Write-Host (" " + "-" * 100)
    foreach ($sup in $Suppressions) {
        $daysLeft  = ($sup.Expires - $today).Days
        $expiryStr = "$($sup.Expires.ToString('yyyy-MM-dd')) (${daysLeft}d left)"
        $resource  = if ($sup.Resource)    { $sup.Resource }    else { "*" }
        $sub       = if ($sup.Subscription) { $sup.Subscription } else { "*" }
        $justif    = if ($sup.Justification.Length -gt 70) { $sup.Justification.Substring(0, 70) } else { $sup.Justification }
        Write-Host (" {0,-10} {1,-25} {2,-20} {3,-14} {4}" -f $sup.ControlId, $resource, $sub, $expiryStr, $justif)
    }
    Write-Host ""
}