modules/normalizers/Normalize-IdentityGraphExpansion.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for identity-graph-expansion results.
.DESCRIPTION
    The wrapper already returns v2 FindingRow objects (built via New-FindingRow),
    so the normalizer is largely a pass-through. Its responsibility is to:
      1. Filter out malformed findings.
      2. Re-canonicalise the EntityId defensively.
      3. Surface edge counts on the result envelope so the orchestrator can log them.
#>

[CmdletBinding()]
param ()

. "$PSScriptRoot\..\shared\Schema.ps1"
. "$PSScriptRoot\..\shared\Canonicalize.ps1"

function Normalize-IdentityGraphExpansion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject] $ToolResult
    )

    if (-not $ToolResult -or $ToolResult.Status -ne 'Success' -or -not $ToolResult.PSObject.Properties['Findings']) {
        return @()
    }

    $normalized = [System.Collections.Generic.List[PSCustomObject]]::new()
    $edges = if ($ToolResult.PSObject.Properties['Edges'] -and $ToolResult.Edges) { @($ToolResult.Edges) } else { @() }

    foreach ($edge in $edges) {
        if (-not $edge) { continue }
        $source = if ($edge.PSObject.Properties['Source'] -and $edge.Source) { [string]$edge.Source } else { '' }
        $target = if ($edge.PSObject.Properties['Target'] -and $edge.Target) { [string]$edge.Target } else { '' }
        $relation = if ($edge.PSObject.Properties['Relation'] -and $edge.Relation) { [string]$edge.Relation } else { '' }

        $edgeRefs = @($source, $target, $(if ($relation) { "relation:$relation" } else { $null }) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
        if ($edge.PSObject.Properties['EntityRefs']) {
            $edge.EntityRefs = @($edgeRefs)
        } else {
            $edge | Add-Member -NotePropertyName 'EntityRefs' -NotePropertyValue @($edgeRefs)
        }
    }

    foreach ($f in @($ToolResult.Findings)) {
        if (-not $f) { continue }
        # Findings are already v2; defensively re-canonicalise the EntityId.
        $entityType = if ($f.PSObject.Properties['EntityType'] -and $f.EntityType) { [string]$f.EntityType } else { 'User' }
        $rawEntityId = if ($f.PSObject.Properties['EntityId'] -and $f.EntityId) { [string]$f.EntityId } else { '' }
        $canonical = $rawEntityId
        if ($rawEntityId) {
            try {
                $canonical = (ConvertTo-CanonicalEntityId -RawId $rawEntityId -EntityType $entityType).CanonicalId
            } catch {
                $canonical = $rawEntityId.ToLowerInvariant()
            }
        }
        if ($f.PSObject.Properties['EntityId']) { $f.EntityId = $canonical }
        # Severity must be one of the five enum values; coerce anything unknown to Info.
        if ($f.PSObject.Properties['Severity']) {
            $rawSeverity = [string]$f.Severity
            switch ($rawSeverity.ToLowerInvariant()) {
                'critical' { $f.Severity = 'Critical' }
                'high'     { $f.Severity = 'High' }
                'medium'   { $f.Severity = 'Medium' }
                'low'      { $f.Severity = 'Low' }
                'info'     { $f.Severity = 'Info' }
                default    {
                    # Issue #187 / F4: log so wrapper regressions are visible
                    # instead of silently downgraded.
                    $fid = if ($f.PSObject.Properties['Id']) { [string]$f.Id } else { '<no-id>' }
                    Write-Warning "identity-graph-expansion normalizer: unknown severity '$rawSeverity' coerced to 'Info' for finding $fid"
                    $f.Severity = 'Info'
                }
            }
        }

        if (-not ($f.PSObject.Properties['Frameworks']) -or -not $f.Frameworks -or @($f.Frameworks).Count -eq 0) {
            $frameworks = @(
                @{ Name = 'NIST 800-53'; Controls = @('AC-2', 'AC-6', 'IA-2', 'IA-5'); Pillars = @('Security') },
                @{ Name = 'CIS Controls v8'; Controls = @('5.4', '6.1', '6.8'); Pillars = @('Security') }
            )
            if ($f.PSObject.Properties['Frameworks']) { $f.Frameworks = @($frameworks) } else { $f | Add-Member -NotePropertyName 'Frameworks' -NotePropertyValue @($frameworks) }
        }

        if (-not ($f.PSObject.Properties['Pillar']) -or [string]::IsNullOrWhiteSpace([string]$f.Pillar)) {
            if ($f.PSObject.Properties['Pillar']) { $f.Pillar = 'Security' } else { $f | Add-Member -NotePropertyName 'Pillar' -NotePropertyValue 'Security' }
        }
        if (-not ($f.PSObject.Properties['Impact']) -or [string]::IsNullOrWhiteSpace([string]$f.Impact)) {
            $impact = switch ([string]$f.Severity) {
                'Critical' { 'High' }
                'High' { 'High' }
                'Medium' { 'Medium' }
                default { 'Low' }
            }
            if ($f.PSObject.Properties['Impact']) { $f.Impact = $impact } else { $f | Add-Member -NotePropertyName 'Impact' -NotePropertyValue $impact }
        }
        if (-not ($f.PSObject.Properties['Effort']) -or [string]::IsNullOrWhiteSpace([string]$f.Effort)) {
            $effort = switch ([string]$f.Severity) {
                'Critical' { 'High' }
                'High' { 'Medium' }
                'Medium' { 'Medium' }
                default { 'Low' }
            }
            if ($f.PSObject.Properties['Effort']) { $f.Effort = $effort } else { $f | Add-Member -NotePropertyName 'Effort' -NotePropertyValue $effort }
        }
        if (-not ($f.PSObject.Properties['DeepLinkUrl']) -or [string]::IsNullOrWhiteSpace([string]$f.DeepLinkUrl)) {
            $deepLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview'
            if ($f.PSObject.Properties['DeepLinkUrl']) { $f.DeepLinkUrl = $deepLink } else { $f | Add-Member -NotePropertyName 'DeepLinkUrl' -NotePropertyValue $deepLink }
        }
        if (-not ($f.PSObject.Properties['MitreTactics']) -or -not $f.MitreTactics -or @($f.MitreTactics).Count -eq 0) {
            $tactics = @('TA0008', 'TA0004')
            if ($f.PSObject.Properties['MitreTactics']) { $f.MitreTactics = @($tactics) } else { $f | Add-Member -NotePropertyName 'MitreTactics' -NotePropertyValue @($tactics) }
        }
        if (-not ($f.PSObject.Properties['MitreTechniques']) -or -not $f.MitreTechniques -or @($f.MitreTechniques).Count -eq 0) {
            $techniques = @('T1078', 'T1098')
            if ($f.PSObject.Properties['MitreTechniques']) { $f.MitreTechniques = @($techniques) } else { $f | Add-Member -NotePropertyName 'MitreTechniques' -NotePropertyValue @($techniques) }
        }
        if (-not ($f.PSObject.Properties['ToolVersion']) -or [string]::IsNullOrWhiteSpace([string]$f.ToolVersion)) {
            $toolVersion = if ($ToolResult.PSObject.Properties['ToolVersion'] -and $ToolResult.ToolVersion) { [string]$ToolResult.ToolVersion } else { 'identity-graph-expansion@1.0' }
            if ($f.PSObject.Properties['ToolVersion']) { $f.ToolVersion = $toolVersion } else { $f | Add-Member -NotePropertyName 'ToolVersion' -NotePropertyValue $toolVersion }
        }

        $entityRefs = [System.Collections.Generic.List[string]]::new()
        if ($f.PSObject.Properties['EntityRefs'] -and $f.EntityRefs) {
            foreach ($existingRef in @($f.EntityRefs)) {
                if (-not [string]::IsNullOrWhiteSpace([string]$existingRef)) {
                    $entityRefs.Add([string]$existingRef) | Out-Null
                }
            }
        }
        if (-not [string]::IsNullOrWhiteSpace($canonical)) {
            $entityRefs.Add($canonical) | Out-Null
        }
        foreach ($edge in $edges) {
            if (-not $edge) { continue }
            $source = if ($edge.PSObject.Properties['Source'] -and $edge.Source) { [string]$edge.Source } else { '' }
            $target = if ($edge.PSObject.Properties['Target'] -and $edge.Target) { [string]$edge.Target } else { '' }
            $relation = if ($edge.PSObject.Properties['Relation'] -and $edge.Relation) { [string]$edge.Relation } else { '' }
            if ($source -eq $canonical -or $target -eq $canonical) {
                if ($source) { $entityRefs.Add($source) | Out-Null }
                if ($target) { $entityRefs.Add($target) | Out-Null }
                if ($relation) { $entityRefs.Add("relation:$relation") | Out-Null }
            }
        }
        $dedupedRefs = @($entityRefs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
        if ($f.PSObject.Properties['EntityRefs']) { $f.EntityRefs = @($dedupedRefs) } else { $f | Add-Member -NotePropertyName 'EntityRefs' -NotePropertyValue @($dedupedRefs) }

        $normalized.Add($f)
    }

    return @($normalized)
}