modules/shared/Suppression.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Cross-run suppression (false-positive / accepted-risk) support.
.DESCRIPTION
    Large scans surface findings a team has already reviewed and accepted.
    Without a first-class suppression mechanism every re-run reproduces the
    full list and the counts never reflect the team's triage decisions.

    Identity is the hard part. The FindingRow 'Id' field cannot be used as a
    suppression key: 37 of the 38 normalizers fall back to
    [guid]::NewGuid() when the underlying tool supplies no id of its own, so
    Id is deliberately unique per run. A suppression list keyed on it would
    silently stop matching after the very first re-scan, which is worse than
    having no suppression at all because the failure is invisible.

    Get-FindingKey therefore derives a key from fields that are stable by
    construction:

      Source - the tool name, a required FindingRow field.
      RuleId - the tool's own rule identifier when present. Only 27 of 38
                 normalizers populate it (WARA, the highest-volume tool, does
                 not), so Title is used as the fallback. Title is required and
                 non-empty on every FindingRow.
      EntityId - required, and already canonicalised (lowercased ARM ids,
                 tenant:{guid} and so on) by ConvertTo-CanonicalEntityId, so
                 casing and formatting drift cannot change the key.

    Findings are marked, never dropped: raw data keeps every finding so an
    audit trail survives, and only the actionable counts and default report
    views exclude suppressed rows.
#>

[CmdletBinding()]
param ()

$script:SuppressionSchemaVersion = '1.0'

function Get-FindingKey {
    <#
    .SYNOPSIS
        Computes a stable, run-independent identity for a finding.
    .DESCRIPTION
        SHA-256 over 'source|rule|entity', truncated to 16 hex characters.

        Truncation is safe here because the key is a lookup handle for a
        human-curated list, not a security boundary: 64 bits over a few
        thousand findings leaves collision probability negligible, and a
        collision would at worst suppress one extra finding in the same
        tenant rather than grant access to anything.

        Components are lowercased and trimmed so that incidental casing
        differences between tool versions do not invalidate a curated list.
    .PARAMETER Finding
        A v3 FindingRow (or any object exposing Source, RuleId/Title, EntityId).
    .OUTPUTS
        [string] 16-character lowercase hex, or '' when identity is incomplete.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object] $Finding
    )
    process {
        if ($null -eq $Finding) { return '' }

        $source = Get-SuppressionField -Object $Finding -Name 'Source'
        $entity = Get-SuppressionField -Object $Finding -Name 'EntityId'
        $rule = Get-SuppressionField -Object $Finding -Name 'RuleId'
        if ([string]::IsNullOrWhiteSpace($rule)) {
            $rule = Get-SuppressionField -Object $Finding -Name 'Title'
        }

        # Source and EntityId are both required FindingRow fields. If either is
        # missing the object is not a well-formed finding, and returning a key
        # anyway would let unrelated malformed rows collide on the same handle.
        if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($entity)) { return '' }
        if ([string]::IsNullOrWhiteSpace($rule)) { return '' }

        return Get-SuppressionHash -Source $source -Rule $rule -Entity $entity
    }
}

function Get-SuppressionHash {
    <#
    .SYNOPSIS
        Shared hashing used by both Get-FindingKey and explicit list entries.
    .DESCRIPTION
        Kept separate so a hand-authored suppression entry that specifies
        source/rule/entity produces byte-identical output to the key computed
        from a live finding. If these two ever diverged, hand-authored entries
        would silently never match.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Source,
        [Parameter(Mandatory)] [string] $Rule,
        [Parameter(Mandatory)] [string] $Entity
    )
    $material = '{0}|{1}|{2}' -f $Source.Trim().ToLowerInvariant(), $Rule.Trim().ToLowerInvariant(), $Entity.Trim().ToLowerInvariant()
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($material))
    } finally {
        $sha.Dispose()
    }
    return -join ($bytes[0..7] | ForEach-Object { $_.ToString('x2') })
}

function Get-SuppressionField {
    <#
    .SYNOPSIS
        StrictMode-safe property read.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Object,
        [Parameter(Mandatory)] [string] $Name
    )
    if ($Object -is [hashtable]) {
        if ($Object.ContainsKey($Name) -and $null -ne $Object[$Name]) { return [string]$Object[$Name] }
        return ''
    }
    if ($Object.PSObject.Properties[$Name]) {
        $value = $Object.$Name
        if ($null -ne $value) { return [string]$value }
    }
    return ''
}

function Import-SuppressionList {
    <#
    .SYNOPSIS
        Loads and validates a suppression list from disk.
    .DESCRIPTION
        Accepts JSON in the shape:

            {
              "schemaVersion": "1.0",
              "suppressions": [
                { "key": "a1b2c3d4e5f60718", "reason": "Risk accepted", "expires": "2026-12-31" },
                { "source": "wara", "ruleId": "...", "entityId": "/subscriptions/...", "reason": "..." }
              ]
            }

        Two entry forms are supported on purpose. The 'key' form is what a
        machine-generated list looks like (an interactive report exporting a
        triage decision), while the source/ruleId/entityId triple is
        reviewable in a pull request by someone who cannot run the hash. Both
        resolve through Get-SuppressionHash, so they cannot drift apart.

        'reason' is mandatory. A suppression list without reasons becomes
        unauditable within one staff rotation, and the whole point of the
        feature is to make accepted risk explicit rather than invisible.

        'expires' is optional and, when present, must be a parseable date.
        An expired entry is reported and ignored rather than silently applied,
        so accepted risk has to be re-confirmed instead of persisting forever.

        A malformed file is a hard error: silently scanning without the
        suppressions the operator asked for would misreport the risk posture.
        Individual malformed entries are collected and reported together so a
        long list can be fixed in one pass.
    .OUTPUTS
        [pscustomobject] with Entries (hashtable keyed by hash) and Errors.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path,

        [datetime] $Now = [datetime]::UtcNow
    )

    $result = [pscustomobject]@{
        Entries = @{}
        Errors  = @()
        Expired = @()
    }

    if (-not (Test-Path -LiteralPath $Path)) {
        $result.Errors = @("Suppression file not found: $Path")
        return $result
    }

    try {
        $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
    } catch {
        $result.Errors = @("Suppression file could not be read: $([string]$_)")
        return $result
    }

    if ([string]::IsNullOrWhiteSpace($raw)) {
        $result.Errors = @("Suppression file is empty: $Path")
        return $result
    }

    try {
        $doc = $raw | ConvertFrom-Json -ErrorAction Stop
    } catch {
        $result.Errors = @("Suppression file is not valid JSON: $([string]$_)")
        return $result
    }

    $entries = @()
    if ($doc.PSObject.Properties['suppressions'] -and $doc.suppressions) {
        $entries = @($doc.suppressions)
    } elseif ($doc -is [array]) {
        # Tolerate a bare array; it is the obvious thing to hand-write.
        $entries = @($doc)
    } elseif ($doc.PSObject.Properties['key'] -or $doc.PSObject.Properties['source']) {
        # ConvertFrom-Json collapses a single-element array to a bare object,
        # so a one-entry hand-written list arrives here rather than above.
        $entries = @($doc)
    } else {
        $result.Errors = @("Suppression file has no 'suppressions' array.")
        return $result
    }

    $errors = [System.Collections.Generic.List[string]]::new()
    $expired = [System.Collections.Generic.List[string]]::new()
    $map = @{}
    $index = 0

    foreach ($entry in $entries) {
        $index++
        if ($null -eq $entry) { continue }

        $reason = Get-SuppressionField -Object $entry -Name 'reason'
        if ([string]::IsNullOrWhiteSpace($reason)) {
            $errors.Add("Entry $index is missing a 'reason'.")
            continue
        }

        $key = Get-SuppressionField -Object $entry -Name 'key'
        if ([string]::IsNullOrWhiteSpace($key)) {
            $source = Get-SuppressionField -Object $entry -Name 'source'
            $entity = Get-SuppressionField -Object $entry -Name 'entityId'
            $rule = Get-SuppressionField -Object $entry -Name 'ruleId'
            if ([string]::IsNullOrWhiteSpace($rule)) {
                $rule = Get-SuppressionField -Object $entry -Name 'title'
            }
            if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($entity) -or [string]::IsNullOrWhiteSpace($rule)) {
                $errors.Add("Entry $index needs either 'key', or all of 'source', 'entityId' and one of 'ruleId'/'title'.")
                continue
            }
            $key = Get-SuppressionHash -Source $source -Rule $rule -Entity $entity
        } else {
            $key = $key.Trim().ToLowerInvariant()
            if ($key -notmatch '^[0-9a-f]{16}$') {
                $errors.Add("Entry $index has key '$key', which is not a 16-character hex finding key.")
                continue
            }
        }

        $expiresRaw = Get-SuppressionField -Object $entry -Name 'expires'
        if (-not [string]::IsNullOrWhiteSpace($expiresRaw)) {
            [datetime] $expiresAt = [datetime]::MinValue
            if (-not [datetime]::TryParse($expiresRaw, [ref] $expiresAt)) {
                $errors.Add("Entry $index has an unparseable 'expires' value '$expiresRaw'.")
                continue
            }
            if ($expiresAt -lt $Now) {
                $expired.Add("$key (expired $($expiresAt.ToString('yyyy-MM-dd')): $reason)")
                continue
            }
        }

        $map[$key] = [pscustomobject]@{
            Key     = $key
            Reason  = $reason
            Expires = $expiresRaw
        }
    }

    $result.Entries = $map
    $result.Errors = @($errors)
    $result.Expired = @($expired)
    return $result
}

function Set-FindingSuppression {
    <#
    .SYNOPSIS
        Marks findings that match a suppression list.
    .DESCRIPTION
        Findings are annotated in place with Suppressed / SuppressionReason /
        FindingKey and are always retained. Callers exclude them from
        actionable counts and default views; the raw data keeps them so the
        decision stays auditable and a suppression can be reversed without
        re-running the scan.

        FindingKey is stamped on every finding, matched or not, because the
        interactive report needs a key to offer "suppress this" against, and
        computing it once here keeps it consistent with what the matcher used.
    .OUTPUTS
        [pscustomobject] summary with Total, Suppressed and UnusedKeys.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object[]] $Findings,

        [Parameter(Mandatory)]
        [hashtable] $Entries
    )

    $total = 0
    $suppressed = 0
    $used = @{}

    foreach ($finding in @($Findings)) {
        if ($null -eq $finding) { continue }
        $total++

        $key = Get-FindingKey -Finding $finding
        Set-SuppressionProperty -Finding $finding -Name 'FindingKey' -Value $key

        $isSuppressed = $false
        $reason = ''
        if ($key -and $Entries.ContainsKey($key)) {
            $isSuppressed = $true
            $reason = [string]$Entries[$key].Reason
            $used[$key] = $true
            $suppressed++
        }

        Set-SuppressionProperty -Finding $finding -Name 'Suppressed' -Value $isSuppressed
        Set-SuppressionProperty -Finding $finding -Name 'SuppressionReason' -Value $reason
    }

    # Unused keys are surfaced rather than ignored: they usually mean a
    # resource was deleted or a rule id changed, and a suppression list that
    # quietly stops matching is exactly the failure mode this design avoids.
    $unused = @($Entries.Keys | Where-Object { -not $used.ContainsKey($_) })

    return [pscustomobject]@{
        Total      = $total
        Suppressed = $suppressed
        UnusedKeys = $unused
    }
}

function Set-SuppressionProperty {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Finding,
        [Parameter(Mandatory)] [string] $Name,
        [Parameter()] [AllowNull()] [object] $Value
    )
    if ($Finding.PSObject.Properties[$Name]) {
        $Finding.$Name = $Value
    } else {
        $Finding | Add-Member -MemberType NoteProperty -Name $Name -Value $Value -Force
    }
}