Private/Render/PpaRedact.ps1

# PpaRedact.ps1 - render-time redaction (P6). One redaction function applied at the
# display boundary: both HTML encoders (ConvertTo-PpaHtmlText / ConvertTo-PpaHtmlAttr)
# call ConvertTo-PpaRedactedText before encoding, so coverage is consistent and
# testable. Finding data in memory and the JSON export are never touched.
#
# Default -Redact scope: UPNs/email addresses -> user01@[redacted]; *.onmicrosoft.com
# domains and any domain carrying the tenant's own base label (seeded from the meta
# tenant hint and operator) -> [redacted-domain-01]. Tokens are stable per unique
# value within a run so the report stays internally consistent. Microsoft Learn /
# portal URLs never match (only seeded tenant domains and onmicrosoft.com patterns).
#
# Stricter -RedactNames additionally pseudonymizes policy/label names (harvested from
# drill-down tables whose first column header names a Policy or Label) as Policy-01 /
# Label-01 tokens, replaced longest-first everywhere the name appears - including
# remarks and remediation snippet text.
#
# ASCII-only source (Windows PowerShell 5.1).

Set-StrictMode -Off

$script:PpaRedactState = $null

function Initialize-PpaRedaction {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] $Normalized,
        [switch]$RedactNames
    )

    $state = @{
        RedactNames = [bool]$RedactNames
        UserMap     = @{}
        DomainMap   = @{}
        NameMap     = @{}
        NameList    = @()
        PolicyCount = 0
        LabelCount  = 0
        BaseLabels  = @()
    }

    # Seed the tenant's own domain base labels from the meta header (tenant hint and
    # operator UPN). Only domains derived from these seeds - plus any *.onmicrosoft.com
    # string - are masked; unrelated domains (learn.microsoft.com) are left alone.
    $seedDomains = New-Object System.Collections.Generic.List[string]
    foreach ($seed in @([string]$Normalized.meta.tenant, [string]$Normalized.meta.operator, [string]$Normalized.meta.organization)) {
        if ([string]::IsNullOrWhiteSpace($seed)) { continue }
        foreach ($m in [regex]::Matches($seed, '@([A-Za-z0-9.-]+\.[A-Za-z]{2,})')) { $seedDomains.Add($m.Groups[1].Value) }
        foreach ($m in [regex]::Matches($seed, '\b([A-Za-z0-9-]+\.onmicrosoft\.com)\b')) { $seedDomains.Add($m.Groups[1].Value) }
        if ($seed -match '^\s*([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)\s*$') { $seedDomains.Add($Matches[1]) }
    }
    $state.BaseLabels = @($seedDomains | ForEach-Object { ([string]$_ -split '\.')[0].ToLower() } |
        Where-Object { $_ -and $_.Length -ge 3 } | Select-Object -Unique)

    # Harvest policy/label names for -RedactNames from the same finding objects the
    # body renders: first-column cells of tables whose first header names the kind.
    if ($RedactNames) {
        foreach ($sec in @($Normalized.sections)) {
            foreach ($f in @($sec.findings)) {
                if ($null -eq $f -or $null -eq $f.table) { continue }
                $cols = @($f.table.columns)
                if ($cols.Count -eq 0) { continue }
                $head = [string]$cols[0]
                $kind = if ($head -match '(?i)policy') { 'Policy' } elseif ($head -match '(?i)label') { 'Label' } else { $null }
                if (-not $kind) { continue }
                foreach ($row in @($f.table.rows)) {
                    $name = [string](@($row.cells)[0])
                    if ([string]::IsNullOrWhiteSpace($name) -or $name.Trim().Length -lt 3) { continue }
                    $name = $name.Trim()
                    $key  = $name.ToLower()
                    if ($state.NameMap.ContainsKey($key)) { continue }
                    if ($kind -eq 'Policy') { $state.PolicyCount++; $token = 'Policy-{0:00}' -f $state.PolicyCount }
                    else                    { $state.LabelCount++;  $token = 'Label-{0:00}'  -f $state.LabelCount }
                    $state.NameMap[$key] = [pscustomobject]@{ Name = $name; Token = $token }
                }
            }
        }
        # Wave 4 closeout: the coverage matrix renders contributor policy names in
        # cell tooltips; harvest them from the CoverageModel too, so no name can
        # slip past redaction just because a drill-down table did not carry it.
        if ($Normalized.PSObject.Properties.Name -contains 'coverage' -and $null -ne $Normalized.coverage) {
            foreach ($cell in @($Normalized.coverage.cells)) {
                foreach ($name in @($cell.contributors)) {
                    $n = [string]$name
                    if ([string]::IsNullOrWhiteSpace($n) -or $n.Trim().Length -lt 3) { continue }
                    $n = $n.Trim(); $key = $n.ToLower()
                    if ($state.NameMap.ContainsKey($key)) { continue }
                    $state.PolicyCount++
                    $state.NameMap[$key] = [pscustomobject]@{ Name = $n; Token = ('Policy-{0:00}' -f $state.PolicyCount) }
                }
            }
        }
        # Longest-first so 'General - 3yr' is consumed before the label 'General'.
        $state.NameList = @($state.NameMap.Values | Sort-Object { $_.Name.Length } -Descending)
    }

    $script:PpaRedactState = $state
}

function Clear-PpaRedaction {
    $script:PpaRedactState = $null
}

function Initialize-PpaDeltaRedaction {
    # Delta-report variant of Initialize-PpaRedaction (Wave 4 spec 4.5): same state
    # shape, same encoders, same stable tokens - but names are harvested from the
    # DELTA MODEL records the report will render (added/removed/modified names and
    # rename annotations), and domain seeds come from the snapshots' tenantIds.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] $Delta,
        [switch]$RedactNames
    )

    $state = @{
        RedactNames = [bool]$RedactNames
        UserMap     = @{}
        DomainMap   = @{}
        NameMap     = @{}
        NameList    = @()
        PolicyCount = 0
        LabelCount  = 0
        BaseLabels  = @()
    }

    $seeds = New-Object System.Collections.Generic.List[string]
    foreach ($t in @([string]$Delta.from.tenantId, [string]$Delta.to.tenantId)) {
        if (-not [string]::IsNullOrWhiteSpace($t)) {
            $base = ([string]$t -split '\.')[0].ToLower()
            if ($base.Length -ge 3) { $seeds.Add($base) }
        }
    }
    $state.BaseLabels = @($seeds | Select-Object -Unique)

    if ($RedactNames) {
        $labelTypes = @('SensitivityLabel', 'RetentionLabel')
        $addName = {
            param($TypeName, $Name)
            if ([string]::IsNullOrWhiteSpace($Name) -or $Name.Trim().Length -lt 3) { return }
            $n = $Name.Trim(); $k = $n.ToLower()
            if ($state.NameMap.ContainsKey($k)) { return }
            if ($labelTypes -contains $TypeName) { $state.LabelCount++; $token = 'Label-{0:00}' -f $state.LabelCount }
            else { $state.PolicyCount++; $token = 'Policy-{0:00}' -f $state.PolicyCount }
            $state.NameMap[$k] = [pscustomobject]@{ Name = $n; Token = $token }
        }
        foreach ($sec in @($Delta.sections)) {
            foreach ($r in @($sec.added))   { & $addName $r.type ([string]$r.name) }
            foreach ($r in @($sec.removed)) { & $addName $r.type ([string]$r.name) }
            foreach ($r in @($sec.modified)) {
                & $addName $r.type ([string]$r.name)
                & $addName $r.type ([string]$r.renameFrom)
                & $addName $r.type ([string]$r.renameTo)
            }
        }
        $state.NameList = @($state.NameMap.Values | Sort-Object { $_.Name.Length } -Descending)
    }

    $script:PpaRedactState = $state
}

function ConvertTo-PpaRedactedText {
    # The single redaction function. Pass-through when redaction is not active.
    param([AllowNull()][string]$Text)
    $state = $script:PpaRedactState
    if ($null -eq $state -or [string]::IsNullOrEmpty($Text)) { return $Text }

    # 1. UPNs / email addresses (before domains, so the address is tokenized whole).
    $Text = [regex]::Replace($Text, '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', {
        param($m)
        $s = $script:PpaRedactState
        $k = $m.Value.ToLower()
        if (-not $s.UserMap.ContainsKey($k)) { $s.UserMap[$k] = ('user{0:00}@[redacted]' -f ($s.UserMap.Count + 1)) }
        $s.UserMap[$k]
    })

    # 2. Tenant domains: every *.onmicrosoft.com, plus any domain whose labels include
    # a seeded tenant base label (contoso.com, contoso.sharepoint.com, ...).
    $domainEval = {
        param($m)
        $s = $script:PpaRedactState
        $k = $m.Value.ToLower()
        if (-not $s.DomainMap.ContainsKey($k)) { $s.DomainMap[$k] = ('[redacted-domain-{0:00}]' -f ($s.DomainMap.Count + 1)) }
        $s.DomainMap[$k]
    }
    $Text = [regex]::Replace($Text, '\b[A-Za-z0-9-]+\.onmicrosoft\.com\b', $domainEval)
    foreach ($base in $state.BaseLabels) {
        $pattern = '\b(?:[A-Za-z0-9-]+\.)*' + [regex]::Escape($base) + '(?:\.[A-Za-z0-9-]+)+\b'
        $Text = [regex]::Replace($Text, $pattern, $domainEval, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
    }

    # 3. Policy/label names (stricter -RedactNames only), longest-first, everywhere.
    # Explicit lookarounds instead of \b: names that end (or start) with a non-word
    # character - 'Broad PII (new)' - have no \b at that edge, so \b silently
    # skipped them (found during Wave 4 delta redaction pinning).
    if ($state.RedactNames) {
        foreach ($entry in $state.NameList) {
            $pattern = '(?<![A-Za-z0-9])' + [regex]::Escape($entry.Name) + '(?![A-Za-z0-9])'
            $Text = [regex]::Replace($Text, $pattern, $entry.Token, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        }
    }

    return $Text
}