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