Public/ConvertTo-NSPConditionalAccessSanitizedInventory.ps1

function ConvertTo-NSPConditionalAccessSanitizedInventory {
    <#
    .SYNOPSIS
        Returns a copy of an inventory with every tenant identifier replaced, safe to commit as a test fixture.
    .DESCRIPTION
        Structure, duplicates, and cross-references survive: the same original value always maps
        to the same placeholder, so a policy's excluded user still matches that user's record.
 
        Replaced:
          - GUIDs, with sequential GUID-shaped placeholders. Kept: global Microsoft constants,
            meaning directory role template IDs, policy template IDs, and first-party app IDs of
            the form 0000000X-0000-0000-c000-000000000000.
          - User display names and UPNs: user-NNN / breakglass-NNN (the breakglass hint is kept),
            UPN '<name>@fixture' (no dotted domain, so the repository's client-data gate stays clean).
          - Policy, group, and named-location display names, except standard names: legacy wizard
            policy names, 'Microsoft-managed:' and 'NSP-' policies, group names matching the NSP
            slot patterns (and their _azure companions), and 'US Region'-style or 'NSP-LOC-' locations.
          - The organization name, tenant domain, and every verified domain; e-mail addresses anywhere.
          - IP ranges, with documentation ranges (192.0.2.0/24, 2001:db8::/32) keeping the prefix length.
          - SavedPath is dropped.
 
        With -TenantProfile, the profile is sanitized in the same pass, so its object IDs and
        labels still match the inventory. The result is then @{ Inventory; TenantProfile }.
 
        The result is checked: if any original GUID, domain, UPN, or replaced name still appears,
        this throws instead of returning. The mapping is never returned or written.
    .EXAMPLE
        ConvertTo-NSPConditionalAccessSanitizedInventory -Inventory $inventory |
            ConvertTo-Json -Depth 32 | Set-Content .\Tests\Fixtures\Inventory\dev.sanitized.json
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Inventory,
        $TenantProfile
    )

    if ($Inventory -is [string]) { $Inventory = Get-Content -LiteralPath $Inventory -Raw | ConvertFrom-Json }
    if ($TenantProfile -is [string]) { $TenantProfile = Get-Content -LiteralPath $TenantProfile -Raw | ConvertFrom-Json }
    $copy = ConvertTo-Json -InputObject $Inventory -Depth 32 | ConvertFrom-Json
    $profileCopy = if ($TenantProfile) { ConvertTo-Json -InputObject $TenantProfile -Depth 32 | ConvertFrom-Json } else { $null }

    function Get-Items { param($Value) @($Value | Where-Object { $null -ne $_ }) }
    $counters = @{}
    function Get-Next { param([string]$Kind) $counters[$Kind] = 1 + [int]$counters[$Kind]; '{0}-{1:000}' -f $Kind, $counters[$Kind] }

    # Values that must not survive, checked at the end.
    $secrets = [System.Collections.Generic.List[string]]::new()

    # --- Names -----------------------------------------------------------------------------
    $legacyPolicyNames = @(Get-NSPConditionalAccessDefinition | ForEach-Object { $_['LegacyNames'] } | Where-Object { $_ })

    $policyNames = @{}
    foreach ($policy in Get-Items $copy.Policies) {
        $name = [string]$policy.displayName
        $keep = $name -in $legacyPolicyNames -or $name -like 'Microsoft-managed:*' -or $name -like 'NSP-*'
        if (-not $policyNames.ContainsKey($name)) { $policyNames[$name] = if ($keep) { $name } else { Get-Next 'policy' } }
        if (-not $keep) { $secrets.Add($name) }
        $policy.displayName = $policyNames[$name]
    }

    # Only the legacy wizard patterns (the first of each slot) keep a name; the looser client-naming
    # patterns could keep a name that carries a client's own words.
    $slotPatterns = @(Get-NSPConditionalAccessGroupSlot | ForEach-Object { $_.Patterns[0] }) + 'Group-Travel-*'
    $groupNames = @{}
    foreach ($group in Get-Items $copy.Groups) {
        $name = [string]$group.displayName
        $base = $name -replace '_azure$', ''
        if (-not @($slotPatterns | Where-Object { $base -like $_ }).Count) {
            $secrets.Add($name)
            if (-not $groupNames.ContainsKey($name)) { $groupNames[$name] = Get-Next 'group' }
            $group.displayName = $groupNames[$name]
        }
    }

    foreach ($location in Get-Items $copy.NamedLocations) {
        $name = [string]$location.displayName
        if ($name -notmatch '^[A-Z]{2} Region$' -and $name -notlike 'NSP-LOC-*') {
            $secrets.Add($name)
            $location.displayName = Get-Next 'location'
        }
        $ranges = $location.PSObject.Properties['ipRanges']
        if ($ranges) {
            $index = 0
            foreach ($range in Get-Items $ranges.Value) {
                $index++
                $prefix = ([string]$range.cidrAddress -split '/')[1]
                $range.cidrAddress = if ($range.cidrAddress -match ':') { "2001:db8::$($index.ToString('x'))/$prefix" } else { "192.0.2.$index/$prefix" }
            }
        }
    }

    $users = @{}
    function ConvertTo-FixtureUser {
        param($Record, [string]$IdKey, [string]$NameKey, [string]$UpnKey)
        $id = [string]$Record.$IdKey
        if (-not $users.ContainsKey($id)) {
            $hint = if ("$($Record.$NameKey) $($Record.$UpnKey)" -match '(?i)break.?glass|emergency') { 'breakglass' } else { 'user' }
            $users[$id] = Get-Next $hint
        }
        foreach ($key in $NameKey, $UpnKey) {
            if ($Record.PSObject.Properties[$key] -and $Record.$key) {
                $secrets.Add([string]$Record.$key)
                $Record.$key = if ($key -eq $UpnKey) { "$($users[$id])@fixture" } else { $users[$id] }
            }
        }
    }
    foreach ($user in Get-Items $copy.ReferencedUsers) {
        ConvertTo-FixtureUser $user 'Id' 'DisplayName' 'UserPrincipalName'
        foreach ($key in 'ExcludedBy', 'IncludedBy') {
            if ($user.PSObject.Properties[$key]) { $user.$key = @(Get-Items $user.$key | ForEach-Object { if ($policyNames.ContainsKey($_)) { $policyNames[$_] } else { $_ } }) }
        }
    }
    foreach ($user in Get-Items $copy.SyncAccounts) { ConvertTo-FixtureUser $user 'Id' 'DisplayName' 'UserPrincipalName' }
    foreach ($user in Get-Items $copy.BreakGlassCandidates) { ConvertTo-FixtureUser $user 'id' 'displayName' 'userPrincipalName' }
    if ($copy.PSObject.Properties['AdminRoles'] -and $copy.AdminRoles) {
        foreach ($user in Get-Items $copy.AdminRoles.Users) {
            ConvertTo-FixtureUser $user 'Id' 'DisplayName' 'UserPrincipalName'
            # 'Role via <group>': the group is a role-assignable group with a client's own name.
            $user.Roles = @(Get-Items $user.Roles | ForEach-Object {
                    if ($_ -match '^(?<role>\S+) via (?<group>.+)$') {
                        $role, $group = $Matches.role, $Matches.group
                        $secrets.Add($group)
                        if (-not $groupNames.ContainsKey($group)) { $groupNames[$group] = Get-Next 'group' }
                        "$role via $($groupNames[$group])"
                    } else { $_ }
                })
        }
        if ($copy.AdminRoles.PSObject.Properties['Reason'] -and $copy.AdminRoles.Reason) { $copy.AdminRoles.Reason = 'Read failed (sanitized).' }
    }

    if ($profileCopy) {
        # Profile labels are UPNs and group names; rewrite them from the same maps so they still
        # describe the same objects as the inventory.
        foreach ($kind in 'BreakGlass', 'ServiceAccounts') {
            $entry = $profileCopy.$kind
            if (-not $entry) { continue }
            $ids = @(Get-Items $entry.Ids)
            foreach ($label in Get-Items $entry.Labels) { $secrets.Add([string]$label) }
            $entry.Labels = @($ids | ForEach-Object {
                    if (-not $users.ContainsKey([string]$_)) { $users[[string]$_] = Get-Next 'user' }
                    "$($users[[string]$_])@fixture"
                })
        }
        if ($profileCopy.Groups) {
            foreach ($slot in $profileCopy.Groups.PSObject.Properties) {
                $slot.Value.Labels = @(Get-Items $slot.Value.Labels | ForEach-Object {
                        if ($groupNames.ContainsKey([string]$_)) { $groupNames[[string]$_] } else { $_ }
                    })
            }
        }
        # Users and groups added to policies (Add-NSPConditionalAccessPolicyTarget).
        if ($profileCopy.PSObject.Properties['PolicyTargets'] -and $profileCopy.PolicyTargets) {
            foreach ($policy in $profileCopy.PolicyTargets.PSObject.Properties) {
                foreach ($field in 'IncludeUsers', 'ExcludeUsers', 'IncludeGroups', 'ExcludeGroups') {
                    foreach ($entry in Get-Items $policy.Value.$field) {
                        $secrets.Add([string]$entry.Label)
                        $entry.Label = if ($field -like '*Users') {
                            if (-not $users.ContainsKey([string]$entry.Id)) { $users[[string]$entry.Id] = Get-Next 'user' }
                            "$($users[[string]$entry.Id])@fixture"
                        } elseif ($groupNames.ContainsKey([string]$entry.Label)) { $groupNames[[string]$entry.Label] } else { Get-Next 'group' }
                    }
                }
            }
        }
    }

    $domains = @(Get-Items $copy.Organization.verifiedDomains | ForEach-Object { [string]$_.name }) + @([string]$copy.TenantDomain) |
        Where-Object { $_ } | Sort-Object -Unique | Sort-Object Length -Descending
    $domainMap = @{}
    $n = 0
    foreach ($domain in $domains) { $n++; $domainMap[$domain] = if ($domain -eq $copy.TenantDomain) { 'fixture.onmicrosoft.com' } else { "domain-$n.invalid" } }
    if ($copy.Organization) {
        if ($copy.Organization.displayName) { $secrets.Add([string]$copy.Organization.displayName); $copy.Organization.displayName = 'Fixture Tenant' }
    }
    if ($copy.PSObject.Properties['SavedPath']) { $copy.SavedPath = $null }

    # --- Text: GUIDs, e-mail addresses, domains --------------------------------------------
    $keepGuids = @{}
    foreach ($id in @(Get-Items $copy.RoleTemplates | ForEach-Object { $_.id }) + @((Get-NSPConditionalAccessRoleTable).Values) +
        @(Get-Items $copy.Policies | ForEach-Object { $_.templateId }) + 'd29b2b05-8046-44ba-8758-1e26182fcf32' + 'a92aed5d-d78a-4d16-b381-09adb37eb3b0') {
        if ($id) { $keepGuids[([string]$id).ToLowerInvariant()] = $true }
    }
    $guidMap = @{}
    $bundle = [pscustomobject]@{ Inventory = $copy; TenantProfile = $profileCopy }
    $text = ConvertTo-Json -InputObject $bundle -Depth 32 -Compress
    $text = [regex]::Replace($text, '(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', {
            param($match)
            $guid = $match.Value.ToLowerInvariant()
            if ($keepGuids.ContainsKey($guid) -or $guid -match '^0000000[0-9a-f]-0000-0000-c000-000000000000$') { return $match.Value }
            if (-not $guidMap.ContainsKey($guid)) { $guidMap[$guid] = '00000000-0000-4000-8000-{0:D12}' -f ($guidMap.Count + 1) }
            $guidMap[$guid]
        })
    # Graph annotation keys ('authenticationStrength@odata.context') look like addresses; leave them.
    $text = [regex]::Replace($text, '(?i)[a-z0-9._%+-]+@(?!odata\.)[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}', {
            param($match)
            $secrets.Add($match.Value)
            'email@fixture'
        })
    foreach ($domain in $domainMap.Keys | Sort-Object Length -Descending) {
        $secrets.Add($domain)
        $text = [regex]::Replace($text, [regex]::Escape($domain), $domainMap[$domain], 'IgnoreCase')
    }

    # --- Self-check -----------------------------------------------------------------------
    # GUIDs and domains can sit inside longer strings, so they are checked as substrings. Names and
    # UPNs are checked as whole JSON string values: a user named 'admin' must not trip on 'Global
    # Administrator'.
    $substringChecks = @(@($guidMap.Keys) + @($domainMap.Keys))
    $valueChecks = @($secrets | Where-Object { $_ -and $_ -notin $substringChecks } | Sort-Object -Unique |
            ForEach-Object { ConvertTo-Json -InputObject ([string]$_) -Compress })
    $leaks = @(
        $substringChecks | Where-Object { $text.IndexOf($_, [StringComparison]::OrdinalIgnoreCase) -ge 0 }
        $valueChecks | Where-Object { $text.IndexOf($_, [StringComparison]::OrdinalIgnoreCase) -ge 0 }
    )
    if ($leaks.Count) {
        throw "Sanitization left $($leaks.Count) original identifier(s) in the output; nothing was returned."
    }
    $result = $text | ConvertFrom-Json
    if ($TenantProfile) { $result } else { $result.Inventory }
}