Private/Get-SPMScanObject.ps1

function Get-SPMScanObject {
    <#
    .SYNOPSIS
        Loads a scan (path, FileInfo or object) and normalizes it: legacy permission codes
        are mapped to FC/W/C/R (X* for custom levels) and limited-access noise is stripped.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $InputObject
    )

    $scan = $null
    if ($InputObject -is [System.IO.FileInfo]) {
        $scan = Get-Content -Path $InputObject.FullName -Raw -Encoding utf8 | ConvertFrom-Json
    }
    elseif ($InputObject -is [string]) {
        if (-not (Test-Path -Path $InputObject)) { throw "Scan file not found: $InputObject" }
        $scan = Get-Content -Path $InputObject -Raw -Encoding utf8 | ConvertFrom-Json
    }
    else {
        # round-trip live objects so mutation never touches the caller's data
        $scan = $InputObject | ConvertTo-Json -Depth 64 | ConvertFrom-Json
    }

    $legacy = @{ V = 'FC'; B = 'W'; M = 'C'; L = 'R'; E = 'W' }
    $limitedRole = '(?i)(limited access|beschr.nkter zugriff)'

    foreach ($site in @($scan.sites)) {
        $dropIds = [System.Collections.Generic.HashSet[string]]::new()
        foreach ($p in @($site.principals)) {
            if ([string]$p.name -like 'Limited Access System Group*') { [void]$dropIds.Add([string]$p.id) }
        }
        $site.principals = @($site.principals | Where-Object { -not $dropIds.Contains([string]$_.id) })

        $dropRoles = [System.Collections.Generic.HashSet[string]]::new()
        $newRoles = [ordered]@{}
        if ($site.customRoles) {
            foreach ($prop in $site.customRoles.PSObject.Properties) {
                if ([string]$prop.Value -match $limitedRole) { [void]$dropRoles.Add($prop.Name); continue }
                $key = if ($prop.Name -match '^C(\d+)$') { 'X' + $Matches[1] } else { $prop.Name }
                $newRoles[$key] = $prop.Value
            }
        }
        $site.customRoles = [pscustomobject]$newRoles

        foreach ($n in @($site.nodes)) {
            $n.assignments = @($n.assignments | Where-Object {
                    -not $dropIds.Contains([string]$_.principalId) -and -not $dropRoles.Contains([string]$_.permission)
                })
            foreach ($a in @($n.assignments)) {
                if ($legacy.ContainsKey([string]$a.permission)) { $a.permission = $legacy[[string]$a.permission] }
                elseif ([string]$a.permission -match '^C(\d+)$') { $a.permission = 'X' + $Matches[1] }
            }
        }

        if ($site.stats) {
            $site.stats.principalCount = @($site.principals).Count
            $site.stats.sharingLinkCount = @($site.principals | Where-Object { $_.type -eq 'SharingLink' }).Count
        }
    }

    return $scan
}

function Get-SPMPermRank {
    <#
    .SYNOPSIS
        Rank of a permission code: FC=0, W=1, C=2, R=3, custom=4 (lower is stronger).
    #>

    [CmdletBinding()]
    param([string]$Permission)

    switch ($Permission) {
        'FC' { return 0 }
        'W' { return 1 }
        'C' { return 2 }
        'R' { return 3 }
        default { return 4 }
    }
}

function Get-SPMPersonMap {
    <#
    .SYNOPSIS
        Resolves the effective people of a site: per principal the set of contained person
        keys (UPN, case-insensitive), plus a global person index.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Site
    )

    $groupy = @('SharePointGroup', 'EntraGroup', 'M365Group', 'SecurityGroup', 'SharingLink')
    $perPrincipal = @{}
    $personIndex = [ordered]@{}

    foreach ($p in @($Site.principals)) {
        $set = [System.Collections.Generic.HashSet[string]]::new()

        if ($p.type -eq 'User') {
            $key = ([string]($p.upn ?? $p.name)).Trim().ToLowerInvariant()
            if ($key) {
                [void]$set.Add($key)
                if (-not $personIndex.Contains($key)) {
                    $personIndex[$key] = [pscustomobject]@{ key = $key; name = [string]$p.name; upn = $p.upn }
                }
            }
        }
        elseif ($p.type -in $groupy -and $p.members) {
            $stack = [System.Collections.Generic.Stack[object]]::new()
            foreach ($m in @($p.members)) { $stack.Push($m) }
            while ($stack.Count -gt 0) {
                $m = $stack.Pop()
                if ($null -eq $m) { continue }
                if ($m.type -eq 'User') {
                    $key = ([string]($m.upn ?? $m.name)).Trim().ToLowerInvariant()
                    if (-not $key) { continue }
                    [void]$set.Add($key)
                    if (-not $personIndex.Contains($key)) {
                        $personIndex[$key] = [pscustomobject]@{ key = $key; name = [string]($m.name ?? $m.upn); upn = $m.upn }
                    }
                    elseif (-not $personIndex[$key].upn -and $m.upn) {
                        $personIndex[$key].upn = $m.upn
                    }
                }
                elseif ($m.PSObject.Properties['members'] -and $m.members) {
                    foreach ($c in @($m.members)) { $stack.Push($c) }
                }
            }
        }

        $perPrincipal[[string]$p.id] = $set
    }

    return @{
        PerPrincipal = $perPrincipal
        Persons      = @($personIndex.Values | Sort-Object name)
    }
}