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 } } |