Public/Reportings/Convert-EntraOpsExportToSampleData.ps1
|
#Requires -Version 7.2 function Convert-EntraOpsExportToSampleData { <# .SYNOPSIS Creates a relationship-preserving anonymized copy of EntraOps export data. .DESCRIPTION Processes the Classification, PrivilegedEAM, and TenantGovernance directories below SourcePath. A single mapping is used for the complete run so identifiers and names that occur in more than one export remain related. GUID replacements are derived from Seed. Identity and Azure resource names use stable aliases such as user01, group01, resourcegroup01, and storage01. The source is never modified and the source-to-target mapping is not written to disk. .PARAMETER TenantName Target tenant prefix. Defaults to contoso. Unless TenantInitialDomain is supplied, the target initial domain is derived as <TenantName>.onmicrosoft.com. .PARAMETER TenantInitialDomain Target tenant initial domain and Classification tenant folder name. Use this when the initial domain is not <TenantName>.onmicrosoft.com. .PARAMETER GenerateReports Generate an offline EntraOps Reporting bundle from the anonymized data. The bundle includes EAM Dashboard, Tier Breach Analyzer, Access Path Map, and Configuration Analyzer when its snapshot prerequisites are available. Graph enrichment is disabled. Report generation requires PowerShell 7.4 or later. .PARAMETER ReportDestinationPath Destination for the generated report bundle. Defaults to <DestinationPath>/Reports. .EXAMPLE Import-Module EntraOps Convert-EntraOpsExportToSampleData ` -SourcePath '/path/to/EntraOpsExport' ` -DestinationPath '/path/to/EntraOpsExport-Anonymized' ` -SourceTenantName 'fabrikam' ` -Seed 'sample-data-v1' ` -GenerateReports ` -Verbose .NOTES Progress is displayed by default. Use -ProgressAction SilentlyContinue to hide the progress display, for example in non-interactive automation. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] [string]$SourcePath, [Parameter(Mandatory)] [string]$DestinationPath, [string]$SourceTenantName, [ValidateNotNullOrEmpty()] [string]$TenantName = 'contoso', [ValidatePattern('^[a-zA-Z0-9.-]+$')] [string]$TenantInitialDomain, [ValidateNotNullOrEmpty()] [string]$Seed = ([guid]::NewGuid().Guid), [string[]]$AdditionalSensitivePropertyName = @(), [switch]$GenerateReports, [string]$ReportDestinationPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($GenerateReports -and $PSVersionTable.PSVersion -lt [version]'7.4') { throw '-GenerateReports requires PowerShell 7.4 or later because the EntraOps module requires that version.' } if ([string]::IsNullOrWhiteSpace($TenantInitialDomain)) { $TenantInitialDomain = "$TenantName.onmicrosoft.com" } $script:GuidAliases = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) $script:LabelAliases = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) $script:NameAliases = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) $script:UserAliases = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) $script:ResourceValueAliases = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) $script:AliasCounters = [System.Collections.Generic.Dictionary[string, int]]::new([System.StringComparer]::OrdinalIgnoreCase) $script:EmbeddedAliasPattern = $null $script:AnonymizedStringCache = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) $script:AnonymizedStringCacheResult = $null $script:GuidHmac = $null $script:GuidPattern = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' $script:ResourceTypeAliases = @{ automationaccounts = 'automation' clusters = 'cluster' databases = 'database' factories = 'datafactory' managedclusters = 'cluster' managementgroups = 'managementgroup' namespaces = 'namespace' registries = 'registry' servers = 'server' sites = 'webapp' storageaccounts = 'storage' userassignedidentities = 'managedidentity' vaults = 'keyvault' virtualmachines = 'virtualmachine' virtualnetworks = 'virtualnetwork' workflows = 'logicapp' } function Get-AnonymizedGuid { param([Parameter(Mandatory)][string]$Value) if (-not $script:GuidAliases.ContainsKey($Value)) { if ($null -eq $script:GuidHmac) { $script:GuidHmac = [System.Security.Cryptography.HMACSHA256]::new([System.Text.Encoding]::UTF8.GetBytes($Seed)) } $Hash = $script:GuidHmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("guid:$($Value.ToLowerInvariant())")) $GuidBytes = [byte[]]::new(16) [System.Array]::Copy($Hash, $GuidBytes, 16) $script:GuidAliases[$Value] = ([guid]::new($GuidBytes)).Guid } return $script:GuidAliases[$Value] } function Get-AnonymizedLabel { param( [Parameter(Mandatory)][string]$Category, [Parameter(Mandatory)][string]$Value ) $NormalizedCategory = ($Category.ToLowerInvariant() -replace '[^a-z0-9]', '') if ([string]::IsNullOrWhiteSpace($NormalizedCategory)) { $NormalizedCategory = 'object' } $AliasKey = "$NormalizedCategory`0$Value" if (-not $script:LabelAliases.ContainsKey($AliasKey)) { if (-not $script:AliasCounters.ContainsKey($NormalizedCategory)) { $script:AliasCounters[$NormalizedCategory] = 0 } $script:AliasCounters[$NormalizedCategory]++ $script:LabelAliases[$AliasKey] = '{0}{1:00}' -f $NormalizedCategory, $script:AliasCounters[$NormalizedCategory] } return $script:LabelAliases[$AliasKey] } function Get-IdentityCategory { param([AllowNull()][string]$ObjectType) switch -Regex ($ObjectType) { '^user$' { return 'user' } '^group$' { return 'group' } '^serviceprincipal$' { return 'serviceprincipal' } default { return 'object' } } } function Get-ResourceCategory { param([Parameter(Mandatory)][string]$ResourceType) $NormalizedType = ($ResourceType.ToLowerInvariant() -replace '[^a-z0-9]', '') if ($script:ResourceTypeAliases.ContainsKey($NormalizedType)) { return $script:ResourceTypeAliases[$NormalizedType] } if ($NormalizedType.EndsWith('ies')) { return $NormalizedType.Substring(0, $NormalizedType.Length - 3) + 'y' } if ($NormalizedType.EndsWith('s') -and -not $NormalizedType.EndsWith('ss')) { return $NormalizedType.Substring(0, $NormalizedType.Length - 1) } return $NormalizedType } function Get-AnonymizedResourceName { param( [Parameter(Mandatory)][string]$ResourceType, [Parameter(Mandatory)][string]$Value ) if ($Value -match "(?i)^$($script:GuidPattern)`$") { return $Value } if ($Value -match '[*?]') { # Wildcard scope/role-action patterns (e.g. "*", "rg-prod*") are not resource # names; anonymizing them would corrupt every literal wildcard in the output. return $Value } $Category = Get-ResourceCategory -ResourceType $ResourceType $Alias = Get-AnonymizedLabel -Category $Category -Value $Value if (-not $script:ResourceValueAliases.ContainsKey($Value)) { $script:ResourceValueAliases[$Value] = $Alias } return $Alias } function Convert-AzureResourceId { param([Parameter(Mandatory)][string]$Value) if ($Value -notmatch '(?i)^/(subscriptions|providers|resourcegroups)/') { return $Value } $Segments = $Value.Split('/') for ($Index = 1; $Index -lt $Segments.Count; $Index++) { switch ($Segments[$Index].ToLowerInvariant()) { 'subscriptions' { if ($Index + 1 -lt $Segments.Count) { $Index++ } } 'resourcegroups' { if ($Index + 1 -lt $Segments.Count) { $Segments[$Index + 1] = Get-AnonymizedResourceName -ResourceType 'resourcegroups' -Value $Segments[$Index + 1] $Index++ } } 'providers' { if ($Index + 1 -lt $Segments.Count) { $Index += 2 } while ($Index -lt $Segments.Count) { $ResourceType = $Segments[$Index] if ($Index + 1 -ge $Segments.Count) { break } if ($ResourceType.ToLowerInvariant() -eq 'providers') { # A nested provider namespace (e.g. a role assignment scoped to # a resource under a second /providers/Microsoft.X/ segment): # skip the namespace instead of anonymizing it as a resource value. $Index += 2 continue } $Segments[$Index + 1] = Get-AnonymizedResourceName -ResourceType $ResourceType -Value $Segments[$Index + 1] $Index += 2 } } } } return $Segments -join '/' } function Convert-GuidText { param([Parameter(Mandatory)][string]$Value) return [regex]::Replace( $Value, "(?i)(?<![0-9a-f])$($script:GuidPattern)(?![0-9a-f])", { param($Match) Get-AnonymizedGuid -Value $Match.Value } ) } function Get-UserAlias { param([Parameter(Mandatory)][string]$Value) if ($script:UserAliases.ContainsKey($Value)) { return $script:UserAliases[$Value] } $Alias = Get-AnonymizedLabel -Category 'user' -Value $Value $script:UserAliases[$Value] = $Alias return $Alias } function Resolve-UserIdentityAlias { param([Parameter(Mandatory)][string]$Value) # Get-UserAlias already checks/populates $script:UserAliases itself; only the # NameAliases fallback (an identity registered by its display name, not its UPN) needs # to be checked ahead of it here. if ($script:NameAliases.ContainsKey($Value)) { return $script:NameAliases[$Value] } return Get-UserAlias -Value $Value } function Convert-UserIdentityText { param([Parameter(Mandatory)][string]$Value) $Converted = [regex]::Replace( $Value, '(?i)(?<identity>[a-z0-9.!#$%&''*+/=?^_`{|}~-]+)#EXT#@(?<domain>[a-z0-9.-]+)', { param($Match) $Alias = Resolve-UserIdentityAlias -Value $Match.Value return "$Alias#EXT#@$script:ConvertEntraOpsExportToSampleDataTenantInitialDomain" } ) return [regex]::Replace( $Converted, '(?i)(?<local>[a-z0-9.!#$%&''*+/=?^_`{|}~-]+)@(?<domain>[a-z0-9.-]+\.[a-z]{2,})', { param($Match) if ($Match.Value -match '(?i)#EXT#@') { return $Match.Value } $Alias = Resolve-UserIdentityAlias -Value $Match.Value $TargetDomain = if ($Match.Groups['domain'].Value.EndsWith('.onmicrosoft.com', [System.StringComparison]::OrdinalIgnoreCase)) { $script:ConvertEntraOpsExportToSampleDataTenantInitialDomain } else { "$script:ConvertEntraOpsExportToSampleDataTenantName.com" } return "$Alias@$TargetDomain" } ) } function Convert-AnonymizedString { # Only called during phase 3, once all aliases/EmbeddedAliasPattern are final, so # results can be safely cached: export files repeat the same schema keys and # frequently-repeated values (tier labels, role names, ...) many times over. param([AllowEmptyString()][string]$Value) if ([string]::IsNullOrEmpty($Value)) { return $Value } if ($script:AnonymizedStringCache.TryGetValue($Value, [ref]$script:AnonymizedStringCacheResult)) { return $script:AnonymizedStringCacheResult } $Result = Convert-AnonymizedStringCore -Value $Value $script:AnonymizedStringCache[$Value] = $Result return $Result } function Convert-AnonymizedStringCore { param([AllowEmptyString()][string]$Value) if (-not [string]::IsNullOrWhiteSpace($script:ConvertEntraOpsExportToSampleDataSourceTenantName) -and $Value.Equals("$($script:ConvertEntraOpsExportToSampleDataSourceTenantName).onmicrosoft.com", [System.StringComparison]::OrdinalIgnoreCase)) { return $script:ConvertEntraOpsExportToSampleDataTenantInitialDomain } if (-not [string]::IsNullOrWhiteSpace($script:ConvertEntraOpsExportToSampleDataSourceTenantName) -and $Value -notmatch '[/@]') { # Use a MatchEvaluator (not a replacement string) so that "$0"/"$&"/"${name}" # tokens inside -TenantName are treated as literal text, not regex substitutions. $Value = [regex]::Replace( $Value, "(?i)$([regex]::Escape($script:ConvertEntraOpsExportToSampleDataSourceTenantName))", { $script:ConvertEntraOpsExportToSampleDataTenantName } ) } if ($script:ResourceValueAliases.ContainsKey($Value)) { return $script:ResourceValueAliases[$Value] } if ($script:NameAliases.ContainsKey($Value) -and $Value -notmatch '(@|#EXT#)') { return $script:NameAliases[$Value] } $Converted = Convert-AzureResourceId -Value $Value $Converted = if ($null -ne $script:EmbeddedAliasPattern) { $script:EmbeddedAliasPattern.Replace( $Converted, { param($Match) if ($Match.Value -match '(@|#EXT#)') { return $Match.Value } if ($script:ResourceValueAliases.ContainsKey($Match.Value)) { return $script:ResourceValueAliases[$Match.Value] } return $script:NameAliases[$Match.Value] } ) } else { $Converted } $Converted = Convert-UserIdentityText -Value $Converted $Converted = Convert-GuidText -Value $Converted return $Converted } function Register-PrimaryIdentityNames { param([AllowNull()]$Node) if ($null -eq $Node) { return } if ($Node -is [System.Collections.IDictionary]) { if ($Node.Contains('ObjectDisplayName') -and -not [string]::IsNullOrWhiteSpace([string]$Node['ObjectDisplayName'])) { $Category = Get-IdentityCategory -ObjectType ([string]$Node['ObjectType']) $IdentityKey = if ($Node.Contains('ObjectId') -and $Node['ObjectId']) { [string]$Node['ObjectId'] } else { [string]$Node['ObjectDisplayName'] } $Alias = Get-AnonymizedLabel -Category $Category -Value $IdentityKey if (-not $script:NameAliases.ContainsKey([string]$Node['ObjectDisplayName'])) { $script:NameAliases[[string]$Node['ObjectDisplayName']] = $Alias } if ($Node.Contains('ObjectUserPrincipalName') -and [string]$Node['ObjectUserPrincipalName'] -match '(@|#EXT#)') { $script:UserAliases[[string]$Node['ObjectUserPrincipalName']] = $Alias $script:NameAliases[[string]$Node['ObjectUserPrincipalName']] = $Alias } } foreach ($Entry in $Node.GetEnumerator()) { Register-PrimaryIdentityNames -Node $Entry.Value } return } if ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) { foreach ($Item in $Node) { Register-PrimaryIdentityNames -Node $Item } } } function Register-RelatedNamesAndResources { param([AllowNull()]$Node) if ($null -eq $Node) { return } if ($Node -is [System.Collections.IDictionary]) { foreach ($Entry in $Node.GetEnumerator()) { # Matches both the EntraOps export's PascalCase names (ObjectDisplayName, ...) # and raw Graph/ARM camelCase names (displayName, userPrincipalName, ...), # since TenantGovernance snapshots persist unmodified Graph response objects. $IsSensitiveProperty = $Entry.Key -match '(?i)(DisplayName|DisplayNames|UserPrincipalName|ResourceName)$' -or $AdditionalSensitivePropertyName -contains [string]$Entry.Key if ($IsSensitiveProperty) { $Values = if ($Entry.Value -is [System.Collections.IEnumerable] -and $Entry.Value -isnot [string]) { $Entry.Value } else { @($Entry.Value) } foreach ($SensitiveValue in $Values) { if ($SensitiveValue -is [string] -and -not [string]::IsNullOrWhiteSpace($SensitiveValue) -and -not $script:NameAliases.ContainsKey($SensitiveValue)) { $script:NameAliases[$SensitiveValue] = Get-AnonymizedLabel -Category 'object' -Value $SensitiveValue } } } if ($Entry.Value -is [string] -and $Entry.Value -match '(?i)^/(subscriptions|providers|resourcegroups)/') { $null = Convert-AzureResourceId -Value $Entry.Value } Register-RelatedNamesAndResources -Node $Entry.Value } return } if ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) { foreach ($Item in $Node) { Register-RelatedNamesAndResources -Node $Item } } } function Convert-AnonymizedNode { param([AllowNull()]$Node) if ($null -eq $Node) { return $null } if ($Node -is [string]) { return Convert-AnonymizedString -Value $Node } if ($Node -is [System.Collections.IDictionary]) { $ConvertedObject = [ordered]@{} foreach ($Entry in $Node.GetEnumerator()) { $ConvertedName = Convert-AnonymizedString -Value ([string]$Entry.Key) $ConvertedObject[$ConvertedName] = Convert-AnonymizedNode -Node $Entry.Value } return $ConvertedObject } if ($Node -is [System.Collections.IEnumerable]) { $ConvertedItems = @( foreach ($Item in $Node) { Convert-AnonymizedNode -Node $Item } ) return , $ConvertedItems } return $Node } function Read-JsonDocument { param([Parameter(Mandatory)][string]$Path) try { # -NoEnumerate keeps a top-level JSON array as a single array value instead of # unrolling it onto the pipeline, which would otherwise corrupt round-tripping # of empty ("[]" -> $null) and single-element ("[x]" -> x) arrays. return [System.IO.File]::ReadAllText($Path) | ConvertFrom-Json -AsHashtable -Depth 100 -NoEnumerate } catch { throw "Failed to parse export JSON '$Path': $($_.Exception.Message)" } } function Write-AnonymizationProgress { param( [Parameter(Mandatory)][string]$Phase, [Parameter(Mandatory)][int]$PhaseNumber, [Parameter(Mandatory)][int]$CurrentItem, [Parameter(Mandatory)][int]$TotalItems, [string]$CurrentFile ) $TotalWorkItems = [Math]::Max(1, $TotalItems * 3) $CompletedWorkItems = (($PhaseNumber - 1) * $TotalItems) + $CurrentItem $PercentComplete = [Math]::Min(100, [Math]::Floor(($CompletedWorkItems / $TotalWorkItems) * 100)) $Status = "Phase $PhaseNumber of 3: $Phase ($CurrentItem of $TotalItems)" Write-Progress -Id 1 -Activity 'Anonymizing EntraOps export' -Status $Status ` -CurrentOperation $CurrentFile -PercentComplete $PercentComplete } function New-AnonymizedReportBundle { param( [Parameter(Mandatory)][string]$AnonymizedRoot, [Parameter(Mandatory)][string]$ReportRoot ) # $PSScriptRoot is EntraOps/Public/Reportings, three levels below the repository root. $RepositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../..')) $ReportTemplateRoot = Join-Path $RepositoryRoot 'Reports' $ModuleManifest = Join-Path $RepositoryRoot 'EntraOps/EntraOps.psd1' if (-not (Test-Path -LiteralPath $ReportTemplateRoot -PathType Container)) { throw "EntraOps report templates were not found: $ReportTemplateRoot" } if (-not (Test-Path -LiteralPath $ModuleManifest -PathType Leaf)) { throw "EntraOps module manifest was not found: $ModuleManifest" } if (Test-Path -LiteralPath $ReportRoot) { throw "ReportDestinationPath already exists: $ReportRoot" } Write-Progress -Id 2 -ParentId 1 -Activity 'Generating reports from anonymized data' ` -Status 'Copying static report assets' -PercentComplete 10 Write-Verbose "Copying report assets from $ReportTemplateRoot to $ReportRoot" $null = [System.IO.Directory]::CreateDirectory($ReportRoot) Get-ChildItem -LiteralPath $ReportTemplateRoot -Force | Where-Object Name -NotIn @('.DS_Store') | Copy-Item -Destination $ReportRoot -Recurse -Force # Copied report trees can contain ignored generated datasets from a previous run. # Remove all of them before generating from the anonymized export. Get-ChildItem -LiteralPath $ReportRoot -Recurse -Directory -Force | Where-Object Name -EQ 'data' | ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force } Write-Progress -Id 2 -ParentId 1 -Activity 'Generating reports from anonymized data' ` -Status 'Loading EntraOps reporting commands' -PercentComplete 20 Import-Module $ModuleManifest -Force -ErrorAction Stop $GeneratedReports = [System.Collections.Generic.List[string]]::new() $PrivilegedEamPath = Join-Path $AnonymizedRoot 'PrivilegedEAM' if (Test-Path -LiteralPath $PrivilegedEamPath -PathType Container) { $ReportTasks = @( [ordered]@{ Name = 'EamDashboard' Percent = 35 Command = 'New-EntraOpsPrivilegedEamDashboardData' Parameters = @{ RepoRoot = $AnonymizedRoot ImportPath = $PrivilegedEamPath AppRoot = Join-Path $ReportRoot 'EamDashboard' ResolveLinkedIdentityObjectIds = $false } }, [ordered]@{ Name = 'TierBreachAnalyzer' Percent = 50 Command = 'New-EntraOpsTierBreachAnalyzerData' Parameters = @{ RepoRoot = $AnonymizedRoot ImportPath = $PrivilegedEamPath AppRoot = Join-Path $ReportRoot 'TierBreachAnalyzer' } }, [ordered]@{ Name = 'AccessPathMap' Percent = 65 Command = 'New-EntraOpsAccessPathMapData' Parameters = @{ RepoRoot = $AnonymizedRoot ImportPath = $PrivilegedEamPath AppRoot = Join-Path $ReportRoot 'AccessPathMap' ResolveObjectIdsOutsidePrivilegedEAM = $false } } ) foreach ($ReportTask in $ReportTasks) { Write-Progress -Id 2 -ParentId 1 -Activity 'Generating reports from anonymized data' ` -Status "Generating $($ReportTask.Name)" -PercentComplete $ReportTask.Percent Write-Verbose "Generating $($ReportTask.Name) from $PrivilegedEamPath" $ReportParameters = $ReportTask.Parameters $ReportParameters['Verbose'] = $VerbosePreference -eq 'Continue' & $ReportTask.Command @ReportParameters $GeneratedReports.Add($ReportTask.Name) } } else { Write-Warning "PrivilegedEAM data was not found below $AnonymizedRoot; PrivilegedEAM reports were skipped." } $SnapshotPath = Join-Path $AnonymizedRoot 'TenantGovernance/Snapshots' $SnapshotManifestPath = Join-Path $SnapshotPath '.SnapshotManifest.json' if (Test-Path -LiteralPath $SnapshotManifestPath -PathType Leaf) { Write-Progress -Id 2 -ParentId 1 -Activity 'Generating reports from anonymized data' ` -Status 'Generating ConfigurationAnalyzer' -PercentComplete 80 Write-Verbose "Generating ConfigurationAnalyzer from $SnapshotPath" try { New-EntraOpsTenantGovernanceConfigurationAnalyzerData ` -RepoRoot $AnonymizedRoot ` -ImportPath $SnapshotPath ` -AppRoot (Join-Path $ReportRoot 'ConfigurationAnalyzer') ` -ResolveGroupMembersForPrivilegedAssets $false ` -AllowStaleSnapshot ` -Verbose:($VerbosePreference -eq 'Continue') $GeneratedReports.Add('ConfigurationAnalyzer') } catch { Write-Warning "Configuration Analyzer report was skipped: $($_.Exception.Message)" } } else { Write-Verbose "Configuration Analyzer skipped because no anonymized snapshot manifest exists at $SnapshotManifestPath" } Write-Progress -Id 2 -ParentId 1 -Activity 'Generating reports from anonymized data' ` -Status 'Report bundle complete' -PercentComplete 100 Write-Progress -Id 2 -ParentId 1 -Activity 'Generating reports from anonymized data' -Completed Write-Verbose "Generated report bundle at $ReportRoot with: $($GeneratedReports -join ', ')" return $GeneratedReports.ToArray() } $SourceRoot = [System.IO.Path]::GetFullPath((Resolve-Path -LiteralPath $SourcePath).Path) $DestinationRoot = [System.IO.Path]::GetFullPath($DestinationPath) if ([string]::IsNullOrWhiteSpace($ReportDestinationPath)) { $ReportDestinationPath = Join-Path $DestinationRoot 'Reports' } $ReportRoot = [System.IO.Path]::GetFullPath($ReportDestinationPath) if ($GenerateReports -and $ReportRoot.Equals($DestinationRoot, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'ReportDestinationPath must not be the same as DestinationPath.' } Write-Verbose "Source export: $SourceRoot" Write-Verbose "Destination: $DestinationRoot" if ($GenerateReports) { Write-Verbose "Report destination: $ReportRoot" } $SourcePrefix = $SourceRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar if ($DestinationRoot -eq $SourceRoot -or $DestinationRoot.StartsWith($SourcePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'DestinationPath must be outside SourcePath.' } if ($GenerateReports) { $ReportPrefix = $ReportRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar if ($ReportRoot.Equals($SourceRoot, [System.StringComparison]::OrdinalIgnoreCase) -or $ReportRoot.StartsWith($SourcePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'ReportDestinationPath must be outside SourcePath.' } if ($DestinationRoot.StartsWith($ReportPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'ReportDestinationPath must not be a parent of DestinationPath.' } if ($ReportRoot.StartsWith($DestinationRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { $ReportRelativePath = [System.IO.Path]::GetRelativePath($DestinationRoot, $ReportRoot) $ReportTopLevelDirectory = $ReportRelativePath.Split([System.IO.Path]::DirectorySeparatorChar)[0] if ($ReportTopLevelDirectory -in @('Classification', 'PrivilegedEAM', 'TenantGovernance')) { throw 'ReportDestinationPath must not overlap an anonymized export directory.' } } } if (Test-Path -LiteralPath $DestinationRoot) { throw "DestinationPath already exists: $DestinationRoot" } if ($GenerateReports -and -not $ReportRoot.StartsWith($DestinationRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $ReportRoot)) { throw "ReportDestinationPath already exists: $ReportRoot" } $ExportDirectoryNames = @('Classification', 'PrivilegedEAM', 'TenantGovernance') $ExportDirectories = @(if ((Split-Path -Leaf $SourceRoot) -in $ExportDirectoryNames) { Get-Item -LiteralPath $SourceRoot } else { $ExportDirectoryNames | ForEach-Object { $Candidate = Join-Path $SourceRoot $_ if (Test-Path -LiteralPath $Candidate -PathType Container) { Get-Item -LiteralPath $Candidate } } }) if ($ExportDirectories.Count -eq 0) { throw "No Classification, PrivilegedEAM, or TenantGovernance directories were found below $SourceRoot." } $JsonFiles = @($ExportDirectories | ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Recurse -File -Filter '*.json' | Where-Object FullName -NotMatch '[/\\]Templates[/\\]' } | Sort-Object -Property FullName -Unique) if ($JsonFiles.Count -eq 0) { throw 'No JSON export files were found.' } Write-Verbose "Discovered $($JsonFiles.Count) JSON export file(s) in: $($ExportDirectories.Name -join ', ')" if ([string]::IsNullOrWhiteSpace($SourceTenantName)) { # -SourcePath may point at the export root, or directly at one of its # Classification/PrivilegedEAM/TenantGovernance subdirectories (both are supported). $ClassificationRoot = if ((Split-Path -Leaf $SourceRoot) -eq 'Classification') { $SourceRoot } else { Join-Path $SourceRoot 'Classification' } $TenantDirectory = Get-ChildItem -LiteralPath $ClassificationRoot -Directory -ErrorAction SilentlyContinue | Where-Object Name -Like '*.onmicrosoft.com' | Select-Object -First 1 if ($TenantDirectory) { $SourceTenantName = $TenantDirectory.Name -replace '(?i)\.onmicrosoft\.com$', '' Write-Verbose "Detected source tenant name '$SourceTenantName' from $($TenantDirectory.Name)." } else { Write-Warning 'Source tenant name could not be auto-detected (no Classification/<tenant>.onmicrosoft.com directory was found). The real source tenant name and initial domain may remain unredacted in the output unless -SourceTenantName is supplied.' } } $script:ConvertEntraOpsExportToSampleDataSourceTenantName = $SourceTenantName $script:ConvertEntraOpsExportToSampleDataTenantName = $TenantName $script:ConvertEntraOpsExportToSampleDataTenantInitialDomain = $TenantInitialDomain Write-Verbose "Target tenant: $TenantName ($TenantInitialDomain)" # Each export file is parsed from disk once here (phase 1) and the parsed document is # reused by phases 2 and 3 below, instead of being re-read and re-parsed on every phase. $FileNumber = 0 $ParsedFiles = @(foreach ($JsonFile in $JsonFiles) { $FileNumber++ $RelativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $JsonFile.FullName) Write-AnonymizationProgress -Phase 'Discovering primary identities' -PhaseNumber 1 ` -CurrentItem $FileNumber -TotalItems $JsonFiles.Count -CurrentFile $RelativePath Write-Verbose "[1/3] Reading primary identities from $RelativePath" $Document = Read-JsonDocument -Path $JsonFile.FullName Register-PrimaryIdentityNames -Node $Document [pscustomobject]@{ RelativePath = $RelativePath Document = $Document } }) $FileNumber = 0 foreach ($ParsedFile in $ParsedFiles) { $FileNumber++ Write-AnonymizationProgress -Phase 'Discovering related names and resources' -PhaseNumber 2 ` -CurrentItem $FileNumber -TotalItems $JsonFiles.Count -CurrentFile $ParsedFile.RelativePath Write-Verbose "[2/3] Discovering related names and resources from $($ParsedFile.RelativePath)" Register-RelatedNamesAndResources -Node $ParsedFile.Document } Write-Verbose "Mappings discovered: $($script:NameAliases.Count) names, $($script:ResourceValueAliases.Count) resources." $EmbeddedAliasValues = @( [System.Collections.Generic.HashSet[string]]::new( [string[]](@($script:NameAliases.Keys) + @($script:ResourceValueAliases.Keys) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }), [System.StringComparer]::OrdinalIgnoreCase ) | Sort-Object -Property Length -Descending ) if ($EmbeddedAliasValues.Count -gt 0) { $EmbeddedAliasExpression = ($EmbeddedAliasValues | ForEach-Object { '(?<![a-z0-9])' + [regex]::Escape($_) + '(?![a-z0-9])' }) -join '|' $script:EmbeddedAliasPattern = [regex]::new( $EmbeddedAliasExpression, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::CultureInvariant ) } $GeneratedReports = @() if ($PSCmdlet.ShouldProcess($DestinationRoot, "Create anonymized copy of $($JsonFiles.Count) JSON files")) { $FileNumber = 0 foreach ($ParsedFile in $ParsedFiles) { $FileNumber++ Write-AnonymizationProgress -Phase 'Writing anonymized export' -PhaseNumber 3 ` -CurrentItem $FileNumber -TotalItems $JsonFiles.Count -CurrentFile $ParsedFile.RelativePath $ConvertedPathSegments = $ParsedFile.RelativePath.Split([System.IO.Path]::DirectorySeparatorChar) | ForEach-Object { Convert-AnonymizedString -Value $_ } $TargetPath = Join-Path $DestinationRoot ($ConvertedPathSegments -join [System.IO.Path]::DirectorySeparatorChar) $RelativeTargetPath = [System.IO.Path]::GetRelativePath($DestinationRoot, $TargetPath) Write-Verbose "[3/3] Writing $($ParsedFile.RelativePath) -> $RelativeTargetPath" $TargetDirectory = Split-Path -Parent $TargetPath $null = [System.IO.Directory]::CreateDirectory($TargetDirectory) $ConvertedDocument = Convert-AnonymizedNode -Node $ParsedFile.Document # Bind via -InputObject (not the pipeline) so a top-level array is serialized # as an array of any length, matching the source file's shape. $Json = ConvertTo-Json -InputObject $ConvertedDocument -Depth 100 [System.IO.File]::WriteAllText($TargetPath, $Json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) } if ($GenerateReports) { $GeneratedReports = @(New-AnonymizedReportBundle -AnonymizedRoot $DestinationRoot -ReportRoot $ReportRoot) } $ManifestReportPath = if ($GenerateReports -and $ReportRoot.StartsWith($DestinationRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { [System.IO.Path]::GetRelativePath($DestinationRoot, $ReportRoot) } $Manifest = [ordered]@{ formatVersion = 1 generatedAtUtc = [DateTime]::UtcNow.ToString('o') sourceFiles = $JsonFiles.Count anonymizedGuids = $script:GuidAliases.Count anonymizedNames = $script:NameAliases.Count anonymizedResources = $script:ResourceValueAliases.Count tenantName = $TenantName tenantInitialDomain = $TenantInitialDomain deterministic = $PSBoundParameters.ContainsKey('Seed') reportPath = $ManifestReportPath generatedReports = $GeneratedReports } $ManifestPath = Join-Path $DestinationRoot 'anonymization-manifest.json' [System.IO.File]::WriteAllText($ManifestPath, ($Manifest | ConvertTo-Json) + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) Write-Verbose "Wrote anonymization manifest to $ManifestPath" } if ($null -ne $script:GuidHmac) { $script:GuidHmac.Dispose() } Write-Progress -Id 1 -Activity 'Anonymizing EntraOps export' -Completed Write-Verbose "Anonymization complete: $($script:GuidAliases.Count) GUIDs, $($script:NameAliases.Count) names, and $($script:ResourceValueAliases.Count) resources anonymized." [pscustomobject]@{ DestinationPath = $DestinationRoot SourceFiles = $JsonFiles.Count AnonymizedGuids = $script:GuidAliases.Count AnonymizedNames = $script:NameAliases.Count AnonymizedResources = $script:ResourceValueAliases.Count TenantName = $TenantName TenantInitialDomain = $TenantInitialDomain ReportPath = if ($GenerateReports) { $ReportRoot } else { $null } GeneratedReports = @($GeneratedReports) } } # SIG # Begin signature block # MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCJAU/drin1t4XN # zpwNwOm67mS/pY2amp6G8MhpCHVt9aCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j # AEkoya49fu0nMA0GCSqGSIb3DQEBDAUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK # ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2Vy # dGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5l # dHdvcmsgQ0EwHhcNMjEwNTMxMDY0MzA2WhcNMjkwOTE3MDY0MzA2WjCBgDELMAkG # A1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAl # BgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMb # Q2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMIICIjANBgkqhkiG9w0BAQEFAAOC # Ag8AMIICCgKCAgEAvfl4+ObVgAxknYYblmRnPyI6HnUBfe/7XGeMycxca6mR5rlC # 5SBLm9qbe7mZXdmbgEvXhEArJ9PoujC7Pgkap0mV7ytAJMKXx6fumyXvqAoAl4Va # qp3cKcniNQfrcE1K1sGzVrihQTib0fsxf4/gX+GxPw+OFklg1waNGPmqJhCrKtPQ # 0WeNG0a+RzDVLnLRxWPa52N5RH5LYySJhi40PylMUosqp8DikSiJucBb+R3Z5yet # /5oCl8HGUJKbAiy9qbk0WQq/hEr/3/6zn+vZnuCYI+yma3cWKtvMrTscpIfcRnNe # GWJoRVfkkIJCu0LW8GHgwaM9ZqNd9BjuiMmNF0UpmTJ1AjHuKSbIawLmtWJFfzcV # WiNoidQ+3k4nsPBADLxNF8tNorMe0AZa3faTz1d1mfX6hhpneLO/lv403L3nUlbl # s+V1e9dBkQXcXWnjlQ1DufyDljmVe2yAWk8TcsbXfSl6RLpSpCrVQUYJIP4ioLZb # MI28iQzV13D4h1L92u+sUS4Hs07+0AnacO+Y+lbmbdu1V0vc5SwlFcieLnhO+Nqc # noYsylfzGuXIkosagpZ6w7xQEmnYDlpGizrrJvojybawgb5CAKT41v4wLsfSRvbl # jnX98sy50IdbzAYQYLuDNbdeZ95H7JlI8aShFf6tjGKOOVVPORa5sWOd/7cCAwEA # AaOCAT4wggE6MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLahVDkCw6A/joq8 # +tT4HKbROg79MB8GA1UdIwQYMBaAFAh2zcsH/yT2xc3tu5C84oQ3RnX3MA4GA1Ud # DwEB/wQEAwIBBjAvBgNVHR8EKDAmMCSgIqAghh5odHRwOi8vY3JsLmNlcnR1bS5w # bC9jdG5jYS5jcmwwawYIKwYBBQUHAQEEXzBdMCgGCCsGAQUFBzABhhxodHRwOi8v # c3ViY2Eub2NzcC1jZXJ0dW0uY29tMDEGCCsGAQUFBzAChiVodHRwOi8vcmVwb3Np # dG9yeS5jZXJ0dW0ucGwvY3RuY2EuY2VyMDkGA1UdIAQyMDAwLgYEVR0gADAmMCQG # CCsGAQUFBwIBFhhodHRwOi8vd3d3LmNlcnR1bS5wbC9DUFMwDQYJKoZIhvcNAQEM # BQADggEBAFHCoVgWIhCL/IYx1MIy01z4S6Ivaj5N+KsIHu3V6PrnCA3st8YeDrJ1 # BXqxC/rXdGoABh+kzqrya33YEcARCNQOTWHFOqj6seHjmOriY/1B9ZN9DbxdkjuR # mmW60F9MvkyNaAMQFtXx0ASKhTP5N+dbLiZpQjy6zbzUeulNndrnQ/tjUoCFBMQl # lVXwfqefAcVbKPjgzoZwpic7Ofs4LphTZSJ1Ldf23SIikZbr3WjtP6MZl9M7JYjs # NhI9qX7OAo0FmpKnJ25FspxihjcNpDOO16hO0EoXQ0zF8ads0h5YbBRRfopUofbv # n3l6XYGaFpAP4bvxSgD5+d2+7arszgowggZrMIIEU6ADAgECAhBX5gfP8LabvnxC # XoItEBa/MA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhB # c3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBDb2RlIFNp # Z25pbmcgMjAyMSBDQTAeFw0yNjEwMDYwODI4MjNaFw0yNzEwMDYwODI4MjJaMIGQ # MQswCQYDVQQGEwJERTEdMBsGA1UECAwUUmhpbmVsYW5kLVBhbGF0aW5hdGUxEjAQ # BgNVBAcMCUxhaG5zdGVpbjEeMBwGA1UECgwVT3BlbiBTb3VyY2UgRGV2ZWxvcGVy # MS4wLAYDVQQDDCVPcGVuIFNvdXJjZSBEZXZlbG9wZXIgVGhvbWFzIE5hdW5oZWlt # MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAphp2ljSmVsRB6gvkEq7A # DUMENpKZiDFoAfF1ktD1nCwaVyI+bJ0ROjHwAn4iJgS+vlBDPOUIw2GLHkiqUhOg # HSwzg6gj45GNmt6zxsKP0zPlhMB/fb0zmz7/F7e3yfKCXLfhbHuk0Es9gJ+8Wmet # 9StVwwSE/K0MpW06GKA9nPWyE4N/ADwigDIj9rsTKJR4cve9w8G1b3zohpUc/qMr # onab+HVw5r9b3sOBnnaXC6zvrX4fovRz1ztwfWjRjILLoFG6mW/TuK5F2ICD3Rhl # 9R2rJWWWhzTDMsQlzvX+/SaM74q3OFpIVNdhvtmWU9pKUBRAyYMijcDqKEFGSzrc # 8JFmGMpP5TuroWF+N21d3px2eARvW1mV0DCfFAH+Og20SH/gVW6/ltQQEkH/z/Zt # 49ARyJrgESLOQtlMCIYSoKISdkk4ml4VUS1E/PcbzXEYj3a7m6DOxcd3WuY03UT9 # cnk3p0LMN6LeLYAZgVY+HRloZlH+kW2SkOHNuWgtXvZhAgMBAAGjggF4MIIBdDAM # BgNVHRMBAf8EAjAAMD0GA1UdHwQ2MDQwMqAwoC6GLGh0dHA6Ly9jY3NjYTIwMjEu # Y3JsLmNlcnR1bS5wbC9jY3NjYTIwMjEuY3JsMHMGCCsGAQUFBwEBBGcwZTAsBggr # BgEFBQcwAYYgaHR0cDovL2Njc2NhMjAyMS5vY3NwLWNlcnR1bS5jb20wNQYIKwYB # BQUHMAKGKWh0dHA6Ly9yZXBvc2l0b3J5LmNlcnR1bS5wbC9jY3NjYTIwMjEuY2Vy # MB8GA1UdIwQYMBaAFN10XUwA23ufoHTKsW73PMAywHDNMB0GA1UdDgQWBBTBvGyH # MXj1dMbVnxuof9oP4PY7cDBLBgNVHSAERDBCMAgGBmeBDAEEATA2BgsqhGgBhvZ3 # AgUBBDAnMCUGCCsGAQUFBwIBFhlodHRwczovL3d3dy5jZXJ0dW0ucGwvQ1BTMBMG # A1UdJQQMMAoGCCsGAQUFBwMDMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsF # AAOCAgEAkC165c7/r6y3VeEEpNXas9bQs6ZpKPdJffHkf9xoPi4kWvAIWGc4ocG1 # DJLb18RXiDYShDmT5sb/6I08FeipKi2DNJkuOq6yOTJ3S7+hNFIZCyd6i7WKO8ea # P5I+FAbyJP4+5coZoMg45bUa9JoocaJCm+5n7WiCfN7/EQpClW5Trm4+ZMbp7EMN # F1uLJgGCMnN0SsoUarp7PHlqupMxMC4Uhhe+lAP2ytcSIU81L97/qjrADbLWqICk # 9il5iGM+aZndguq/cmfH9rJj4GRZHWnG6WwnzYD9+KgYlRI6em+/9dq+IRiXe2Bs # thUuIaFW7U2gWw8tBTEYm1UBLHdJx+L1gjJCLs5KT/wy5wU32wZxNDqPUQejUC1x # GvU3cKqmLgRq+yNf3gFVfzqIoggyM3U9wQvnk7nBtCkpjXS2hdx4yxzcmrlWsDHr # z6+6/jeyQySzbYUlFmKnc4n9Mw2soHOajEua5poFARVRS5KgUBuiiQ21YFNYzWBo # qGuECaaAI/C5rGxapMJ3dAOBnHw6NLqBet+ZXBJEccX5y89MLlOQu+jDmJP8HieR # qDLlwfhVZnw4RY8Rus43NZBwhNlVuOUXqocHKmJhKNlekda3qsJj93eQBQOxKJcc # w5O08q9jukpH2+yzFAj+7ll33DuNUgWptogc4Awtbx3glzcEvJQwggaCMIIEaqAD # AgECAhAo8HfBHDa9/l90MkdwJy4DMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYT # AlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMT # G0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQTAeFw0yNjAzMTEwNzM0NTRaFw0z # NjAyMjcwNzM0NTRaMFAxCzAJBgNVBAYTAlBMMSEwHwYDVQQKDBhBc3NlY28gRGF0 # YSBTeXN0ZW1zIFMuQS4xHjAcBgNVBAMMFUNlcnR1bSBUaW1lc3RhbXAgMjAyNjCC # AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALi9zfBw+xI12hi2F9uNQBNO # STkTIi13fuh3DiWybI5392bBfJw6tO7zDs7UqP/rejVf4xFTU3XNOPsuOFkP5SPF # snWwn3VJL+NhNhsuujvYsQtaQnvNo1wk46reTgws1L8OFEdKaxgyqT4zOQx81mLw # NZSqXNU9Rrb9oAJWJF4Mydz+C1ebu0D0+vgM3tqAQ8TU203fqgKCPqIdHloaFqH9 # eyR+8tnpmy2hnnN59ZOtmsuHeRwf5iTOhlhf0d/qqquLOSIGPgGVL1Hj5XBUox2O # E7tQRr+l/xxNbL6OuwBO4Aa2EbuxYg37cpXv5i2/lTL4NASB/duf9W4fjA3Ro+GP # +bC54n2OYiM3Cq8z92ejx8F5HWO2e4/75eC95B9tMw/eq7109GDyW2+2ut5Bn0o8 # MXJTERkyystWbqcnUkomA97qfiDCwvpWuOMdfTOksXe9S+xDxOJaCbiyTivgPulx # cT+tfQTRXBr5UwtPvc+INnafOEilzVHA11tv+pHcylryxBc08cUihf/ttqkZS0bm # x3VxQay8uohaTp0//ECA9vYGW51SKdJMJhXvKyIdBLy8WYNchTt8yRITv9Adxg9J # qGujh+TnP1bW3aQjFtlEl7SzgNeI8OVA/CJAWWUuRT3Au9ToOpHy/flrrknhN4he # Ew7/6ILMv4HSsj0YZ4otAgMBAAGjggFQMIIBTDB1BggrBgEFBQcBAQRpMGcwOwYI # KwYBBQUHMAKGL2h0dHA6Ly9zdWJjYS5yZXBvc2l0b3J5LmNlcnR1bS5wbC9jdHNj # YTIwMjEuY2VyMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u # Y29tMB8GA1UdIwQYMBaAFL5UAi+/QGxzQ86sCSVOnkNEGu7gMAwGA1UdEwEB/wQC # MAAwOQYDVR0fBDIwMDAuoCygKoYoaHR0cDovL3N1YmNhLmNybC5jZXJ0dW0ucGwv # Y3RzY2EyMDIxLmNybDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAOBgNVHQ8BAf8E # BAMCB4AwIgYDVR0gBBswGTAIBgZngQwBBAIwDQYLKoRoAYb2dwIFAQswHQYDVR0O # BBYEFCM5aiirmhKnpKfyH/lFmr+N/oIDMA0GCSqGSIb3DQEBDAUAA4ICAQBm/pOb # rAveTqYFPCSthxNVwBHTW4TfuBQY2KGCdMlH6ALjVPYf2XARNGMTXScR0JQ2/c2L # xLpoHHhRwxFf3d933DhFS165rYj6iPSQKm41rc9AMpTCjMdmai1aK9o870pOzTzL # RhWFVIxm+5Qf+t4V5z14tPoUsAWdfRKD1Zzvzfe5AHlE9rxOZcGROiULxs3Edf7Z # J8RZI0jzD9juj2oEX2XS7BVKuNCK1W0h1mjapv028yqo8UtZh5/1Ib48Yyp88BDY # Shh5PSGUUuS5tYXVfcduIPBe3PdNTYRYdT5VNxkwLgjMzIcr/6SH2fejFJDjnCxj # vXLuarErJg/IdI+hXdGIBDVOGv1zrkF4ktoQvFZIp5DIAYh0GwyUhc87qqn5UaYT # j2+iyiBDSNh7aGCrkuAnqg7Qh7frhX+QhMGeJ9SpLpnG76qN85hLyjvYR6gRYiLT # j8G4fxjqV400/buPw0rBo9yJZyq/8cGdCOLPOYf9qpefTcrZKeO9pg+UJHx8UABb # aTqizRxm8sUSEycccCCFhIit3AI/3wGuSOADeiA676NcQ7+dsP04YvzvdN7pTZNy # g672WpBDJdg0eItHl4yomvNeIOWh1qs4QqOW6TI8Ocsi4uSDTLorxLUDnBTwKQzz # pxd1YUctEgRh32R0J9FkFkeEruJU94RljFr1uDCCBrkwggShoAMCAQICEQCZo4AK # JlU7ZavcboSms+o5MA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAG # A1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVt # IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3Rl # ZCBOZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjE4WhcNMzYwNTE4MDUzMjE4WjBW # MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEu # MSQwIgYDVQQDExtDZXJ0dW0gQ29kZSBTaWduaW5nIDIwMjEgQ0EwggIiMA0GCSqG # SIb3DQEBAQUAA4ICDwAwggIKAoICAQCdI88EMCM7wUYs5zNzPmNdenW6vlxNur3r # Lfi+5OZ+U3iZIB+AspO+CC/bj+taJUbMbFP1gQBJUzDUCPx7BNLgid1TyztVLn52 # NKgxxu8gpyTr6EjWyGzKU/gnIu+bHAse1LCitX3CaOE13rbuHbtrxF2tPU8f253Q # gX6eO8yTbGps1Mg+yda3DcTsOYOhSYNCJiL+5wnjZ9weoGRtvFgMHtJg6i671OPX # IciiHO4Lwo2p9xh/tnj+JmCQEn5QU0NxzrOiRna4kjFaA9ZcwSaG7WAxeC/xoZSx # F1oK1UPZtKVt+yrsGKqWONoK6f5EmBOAVEK2y4ATDSkb34UD7JA32f+Rm0wsr5aj # zftDhA5mBipVZDjHpwzv8bTKzCDUSUuUmPo1govD0RwFcTtMXcfJtm1i+P2UNXad # PyYVKRxKQATHN3imsfBiNRdN5kiVVeqP55piqgxOkyt+HkwIA4gbmSc3hD8ke66t # 9MjlcNg73rZZlrLHsAIV/nJ0mmgSjBI/TthoGJDydekOQ2tQD2Dup/+sKQptalDl # ui59SerVSJg8gAeV7N/ia4mrGoiez+SqV3olVfxyLFt3o/OQOnBmjhKUANoKLYlK # mUpKEFI0PfoT8Q1W/y6s9LTI6ekbi0igEbFUIBE8KDUGfIwnisEkBw5KcBZ3XwnH # mfznwlKo8QIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU # 3XRdTADbe5+gdMqxbvc8wDLAcM0wHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61Pgc # ptE6Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDAGA1Ud # HwQpMCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYI # KwYBBQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0 # dW0uY29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwv # Y3RuY2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0 # cDovL3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQB1iFgP5Y9Q # KJpTnxDsQ/z0O23JmoZifZdEOEmQvo/79PQg9nLF/GJe6ZiUBEyDBHMtFRK0mXj3 # Qv3gL0sYXe+PPMfwmreJHvgFGWQ7XwnfMh2YIpBrkvJnjwh8gIlNlUl4KENTK5DL # qsYPEtRQCw7R6p4s2EtWyDDr/M58iY2UBEqfUU/ujR9NuPyKk0bEcEi62JGxauFY # zZ/yld13fHaZskIoq2XazjaD0pQkcQiIueL0HKiohS6XgZuUtCKA7S6CHttZEsOb # QJ1j2s0urIDdqF7xaXFVaTHKtAuMfwi0jXtF3JJphrJfc+FFILgCbX/uYBPBlbBI # P4Ht4xxk2GmfzMn7oxPITpigQFJFWuzTMUUgdRHTxaTSKRJ/6Uh7ki/pFjf9sUAS # WgxT69QF9Ki4JF5nBIujxZ2sOU9e1HSCJwOfK07t5nnzbs1LbHuAIGJsRJiQ6HX/ # DW1XFOlXY1rc9HufFhWU+7Uk+hFkJsfzqBz3pRO+5aI6u5abI4Qws4YaeJH7H7M8 # X/YNoaArZbV4Ql+jarKsE0+8XvC4DJB+IVcvC9Ydqahi09mjQse4fxfef0L7E3hh # o2O3bLDM6v60rIRUCi2fJT2/IRU5ohgyTch4GuYWefSBsp5NPJh4QRTP9DC3gc5Q # EKtbrTY0Ka87Web7/zScvLmvQBm8JDFpDjCCBrkwggShoAMCAQICEQDn/2nHOzXO # S5Em2HR8aKWHMA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UE # ChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl # cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBO # ZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjA3WhcNMzYwNTE4MDUzMjA3WjBWMQsw # CQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQw # IgYDVQQDExtDZXJ0dW0gVGltZXN0YW1waW5nIDIwMjEgQ0EwggIiMA0GCSqGSIb3 # DQEBAQUAA4ICDwAwggIKAoICAQDpEh8ENe25XXrFppVBvoplf0530W0lddNmjtv4 # YSh/f7eDQKFaIqc7tHj7ox+u8vIsJZlroakUeMS3i3T8aJRC+eQs4FF0GqvkM6+W # ZO8kmzZfxmZaBYmMLs8FktgFYCzywmXeQ1fEExflee2OpbHVk665eXRHjH7MYZIz # Nnjl2m8Hy8ulB9mR8wL/W0v0pjKNT6G0sfrx1kk+3OGosFUb7yWNnVkWKU4qSxLv # 16kJ6oVJ4BSbZ4xMak6JLeB8szrK9vwGDpvGDnKCUMYL3NuviwH1x4gZG0JAXU3x # 2pOAz91JWKJSAmRy/l0s0l5bEYKolg+DMqVhlOANd8Yh5mkQWaMEvBRE/kAGzIqg # WhwzN2OsKIVtO8mf5sPWSrvyplSABAYa13rMYnzwfg08nljZHghquCJYCa/xHK9a # cev9UD7Y+usr15d7mrszzxhF1JOr1Mpup2chNSBlyOObhlSO16rwrffVrg/SzaKf # SndS5swRhr8bnDqNJY9TNyEYvBYpgF95K7p0g4LguR4A++Z1nFIHWVY5v0fNVZmg # zxD9uVo/gta3onGOQj3JCxgYx0KrCXu4yc9QiVwTFLWbNdHFSjBCt5/8Q9pLuRhV # ocdCunhcHudMS1CGQ/Rn0+7P+fzMgWdRKfEOh/hjLrnQ8BdJiYrZNxvIOhM2aa3z # EDHNwwIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUvlQC # L79AbHNDzqwJJU6eQ0Qa7uAwHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61PgcptE6 # Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMDAGA1UdHwQp # MCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYIKwYB # BQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u # Y29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwvY3Ru # Y2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0cDov # L3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQC4k1l3yUwV/ZQH # CKCneqAs8EGTnwEUJLdDpokN/dMhKjK0rR5qX8nIIHzxpQR3TAw2IRw1Uxsr2Pli # G3bCFqSdQTUbfaTq6V3vBzEebDru9QFjqlKnxCF2h1jhLNFFplbPJiW+JSnJTh1f # KEqEdKdxgl9rVTvlxfEJ7exOn25MGbd/wGPwuSmMxRJVO0wnqgS7kmoJjNF9zqee # hFSDDP8ZVkWg4EZ2tIS0M3uZmByRr+1Lkwjjt8AtW83mVnZTyTsOb+FNfwJY7DS4 # FmWhkRbgcHRetreoTirPOr/ozyDKhT8MTSTf6Lttg6s6T/u08mDWw6HK04ZRDfQ9 # sb77QV8mKgO44WGP31vXnVKoWVJpFBjPvjL8/Zck/5wXX2iqjOaLStFOR/IQki+E # hn4zlcgVm22ZVCBPF+l8nAwUUShCtKuSU7GmZLKCmmxQMkSiWILTm8EtVD6AxnJh # oq8EnhjEEyUoflkeRF2WhFiVQOmWTwZRr44IxWGkNJC6tTorW5rl2Zl+2e9JLPYf # 3pStAPMDoPKIjVXd6NW2+fZrNUBeDo2eOa5Fn7Brs/HLQff5Xgris5MeUbdVgDrF # 8uxO6cLPvZPo63j62SsNg55pTWk9fUIF9iPoRbb4QurjoY/woI1RAOKtYtTic6aA # Jq3u83RIPpGXBSJKwx4KJAOZnCDCtTGCBqIwggaeAgEBMGowVjELMAkGA1UEBhMC # UEwxITAfBgNVBAoTGEFzc2VjbyBEYXRhIFN5c3RlbXMgUy5BLjEkMCIGA1UEAxMb # Q2VydHVtIENvZGUgU2lnbmluZyAyMDIxIENBAhBX5gfP8LabvnxCXoItEBa/MA0G # CWCGSAFlAwQCAQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZI # hvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcC # ARUwLwYJKoZIhvcNAQkEMSIEIKTXT1uNqeTEGZ56pRb5GR8IYBMPUf6UXi9DQUka # nRZ8MA0GCSqGSIb3DQEBAQUABIIBgAxMaWmuQme4MZz2CigGrMoW2BLew58LA0X3 # feM3kF6jbPQI7a4PCF2Bj+78dBWvia8u1NguFxlXdGF9nXdQFzm9aOx4Jof9ceSR # m1odcRK1uWXFr26tcHSOdcVwPvIsmZl6NjzPKHbzgcn5pWuJMlE7uJXFNKPJl+fU # wufO1+VxPdXVlOG8ootU0JdvDLUgAjVR+cziLEKYhEFZMkingHPJ2ic6WT8BLoz5 # xLULHQypu7MH3oK9t2Ex/nml5jxjG9whI5yF0jbmN0xxnc91DOtLnF0pk5HIGlX8 # NWr7rn8lqi+1Y30xxn8CqCv29gpZAXb4NzwDZ+ozDBHqlndzYZ7KyN+gQXfr1Toa # CxV5g02Fv0Bu/gEmOaLQ66xBpfcIGu70L3XU6e3QOk4FzZ72Gt3LKed3K0oLdOaJ # d61hlu/2t0FjEvkNAu1SJTzspc1OCsHnJyBWzHYvCStSo+Fpy+2tZtHxjvFKWEjk # NNGRR9pMHUlreny3X36r0/Zbn8TNCaGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID # 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0 # ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ # KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD # MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQxNDZaMDcG # CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY # 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDAehr/cwIcGvObqYhifE7ecBDsbq8pWEgnv # XnZW01oujH9tyhlXlUkVYRAdGlTHcDAwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ # MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh # MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0 # dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI # hvcNAQEBBQAEggIAPAD2CQl+e83pMIzlYnaMkMPa6klQiP+W8+BUJx5e1P1H2TMh # r9hlcDf9flfmitooxtVMywaVMh4Ux09hs6PMnqVcqNqrBEOGaiEio2G/Dpiljtlg # n9zlWFBg+fxN1/R2pWn1hIRenJne9NUzNmKnIs+8Tnqb8GroE3aAvJDfgpwhBy77 # 99snN1kf1dXFiWg8ulE9pxCD7LOq+0sNWHz2sxRMVK46XRc0CJFHMd/cu374RKp+ # zepNvdjqFj5s9z1+h1HWGjn3a4XnS9kCOcXdiykY+Ui24L+Rmbd8F2UoSLUxGsFn # yrRYoo9ixtNWgeyEW8LNhkeeVJm7ptiNV1ls1+vZXaADh+qHmLACY9FODd8IKOv+ # Ht2948A9cfqLGcdMA0VtroreIUX8kNGnNinvdFAIge9FErXw3JQBEZ7gHvHLtQqC # Lw1kC02LfI13XC+/279QNvysLJqnG8QyG6CKErnTyNjHPeyIxj+NPhdvsYaLNKer # 1SVGLJoNGVweW5WHGHDwU+HEpcCdf7DYzNliQFw3Cw7N715krURV1VRzcL0OFpcr # SGpMh1diXWpPYIgOGh0ANthPa1w9oCmbE4zq40+UK8aZKAE5TpqYCK4Vh2o5361w # 9eBGOeW4GpgdzqtUiOVl4X6b+JAT6DSY0sonmDm/cZdNU2MS0S9T8mD88lw= # SIG # End signature block |