Public/Reportings/New-EntraOpsAccessPathMapData.ps1
|
<# .SYNOPSIS Generate (refresh) the embedded dataset for the EntraOps Access Path Map static web app. .DESCRIPTION Transforms EntraOps Privileged EAM data into the Access Path Map dataset using the BloodHound OpenGraph integration (Export-EntraOpsPrivilegedEAMBloodHound) as the canonical graph model: the same node kinds (AZUser, AZGroup, AZServicePrincipal, AZDevice, AZRole, EO_*Role, EO_*RoleAssignment, EO_AdministrativeUnit) and edge kinds (EO_Has*Role, EO_EligibleFor*Role, EO_Has*RoleAssignment, EO_*RoleAssigned, EO_ScopedTo, EO_ClassifiedViaObject, EO_HasWorkAccount, EO_UsesPAW, EO_OwnsDevice, EO_OwnerOf, ...) are emitted. On top of the OpenGraph payload the dataset is enriched for standalone visualization (the OpenGraph export relies on AzureHound to provide principal names): * Principal nodes get displayname, objecttype/subtype, UPN, sync source, restricted management flags and the aggregated classification (tiers + services). * Role and role assignment nodes always get a human-readable name/displayname (the role definition name, optionally with its assignment scope) instead of the raw GUID the BloodHound exporter uses for its own node ids, plus role type, IsPrivileged, the role actions ("matchedactions") and classification (tiers + services) that apply to them. Role nodes are further enriched with description/categories from the optional role catalog files (Classification/Classification_<System>.json, generated separately via Export-EntraOpsClassification*Roles.ps1) when present. * Role and role assignment nodes are cross-referenced against the same curated, documented attack-path catalog the Classification Explorer's Attack Paths view uses (Reports/ClassificationExplorer/content/attack-paths/*.md), matched by role name or role action. Matches are exposed as knownattackpaths/knownattackpathnames/ knownattackpathseverity node properties - this is what "known attack path" means throughout EntraOps Reporting, as distinct from the separate "tier breach" concept below (crossing an Enterprise Access Model tier boundary, whether or not it matches a documented technique). * Edges from principals to roles / role assignments get tier-breach flags computed with the same rules as the Tier Breach Analyzer: a breach is a path where the principal's designated tier (ObjectAdminTierLevel, unclassified => Tier 2) is LESS privileged than the tier of the service reached through the assignment (edge classification). Generates data/access-path-map-data.js exposing `window.ENTRAOPS_APM_DATA` so the static web app works both over HTTP and from the file system (no fetch / CORS required). .PARAMETER TenantId Tenant id used to build role node ids (<roleDefinitionId>@<tenantId>) and the tenant scope node, matching the BloodHound export. Defaults to the ObjectTenantId found in the export. .PARAMETER RepoRoot Path to the EntraOps repository root that contains the PrivilegedEAM export folder. Defaults to the repository this module lives in. .PARAMETER ImportPath Folder with the Privileged EAM export. Defaults to <RepoRoot>/PrivilegedEAM. .PARAMETER AppRoot Path to the AccessPathMap app folder. Defaults to Reports/AccessPathMap. .PARAMETER OutFile Output file. Defaults to <AppRoot>/data/access-path-map-data.js. .PARAMETER ResolveObjectIdsOutsidePrivilegedEAM When $true, edge endpoints that reference an object id which is neither a privileged object in the Privileged EAM export nor a known device (OwnedObjects/Owners/AssociatedPawDevice/AssociatedWorkAccount/ Sponsors can all reference objects outside the classified scope, e.g. a standard, non-privileged work account or a group/application owned by a privileged principal) are resolved via Microsoft Graph (`POST /v1.0/directoryObjects/getByIds`) to their display name and object type, instead of being shown as an anonymous "unresolved" placeholder node. Requires an active Microsoft Graph connection (e.g. via Connect-EntraOps/Connect-MgGraph) with at least directory read permissions (User.Read.All, Group.Read.All, Application.Read.All or Directory.Read.All). Defaults to the `AccessPathMap.ResolveObjectIdsOutsidePrivilegedEAM` setting in EntraOpsConfig.json (at the repository root resolved from -RepoRoot), or $true when that config file/setting is not present. Resolution is best-effort: objects that Graph cannot return remain explicit unresolved placeholders, and no graph edges are dropped. .PARAMETER PassThru Emit the generated payload object to the pipeline. .EXAMPLE New-EntraOpsAccessPathMapData Regenerates the Access Path Map dataset from the PrivilegedEAM folder of this repository. .EXAMPLE New-EntraOpsAccessPathMapData -ImportPath "C:\Exports\PrivilegedEAM" -Verbose -WhatIf Shows what would be generated from an explicit Privileged EAM export without changing any files. #> function New-EntraOpsAccessPathMapData { [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $false)] [System.String]$TenantId, [Parameter(Mandatory = $false)] [System.String]$RepoRoot, [Parameter(Mandatory = $false)] [System.String]$ImportPath, [Parameter(Mandatory = $false)] [System.String]$AppRoot, [Parameter(Mandatory = $false)] [System.String]$OutFile, [Parameter(Mandatory = $false)] [System.Boolean]$ResolveObjectIdsOutsidePrivilegedEAM = $true, [Parameter(Mandatory = $false)] [switch]$PassThru ) # Resolve the app/repository location relative to the module location. $ModuleRoot = $MyInvocation.MyCommand.Module.ModuleBase if ([string]::IsNullOrWhiteSpace($ModuleRoot) -and -not [string]::IsNullOrWhiteSpace($PSScriptRoot)) { $ModuleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) } if ([string]::IsNullOrWhiteSpace($ModuleRoot)) { throw "Unable to resolve the EntraOps module location. Import the module with 'Import-Module <path-to-EntraOps> -Force' and try again." } $RepositoryRoot = Split-Path -Parent $ModuleRoot if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = $RepositoryRoot } if ([string]::IsNullOrWhiteSpace($AppRoot)) { $AppRoot = Join-Path $RepositoryRoot 'Reports/AccessPathMap' } if (-not (Test-Path -LiteralPath $AppRoot -PathType Container)) { throw "Access Path Map app folder not found: $AppRoot. Import the EntraOps module from a repository checkout that contains Reports/AccessPathMap." } if ([string]::IsNullOrWhiteSpace($ImportPath)) { $ImportPath = Join-Path $RepoRoot 'PrivilegedEAM' } if ([string]::IsNullOrWhiteSpace($OutFile)) { $OutFile = Join-Path $AppRoot 'data/access-path-map-data.js' } if (-not (Test-Path -LiteralPath $ImportPath)) { throw "Privileged EAM import path not found: $ImportPath. Run Save-EntraOpsPrivilegedEAMJson first or pass -ImportPath." } # Config-driven default: EntraOpsConfig.json "AccessPathMap.ResolveObjectIdsOutsidePrivilegedEAM" # controls Graph resolution of edge endpoints that are not part of the Privileged EAM export. # -ResolveObjectIdsOutsidePrivilegedEAM always wins over the config file setting when explicitly bound. if (-not $PSBoundParameters.ContainsKey('ResolveObjectIdsOutsidePrivilegedEAM')) { $AccessPathMapConfigFilePath = Join-Path $RepoRoot 'EntraOpsConfig.json' if (Test-Path -LiteralPath $AccessPathMapConfigFilePath -PathType Leaf) { try { $AccessPathMapEntraOpsConfig = Get-Content -LiteralPath $AccessPathMapConfigFilePath -Raw | ConvertFrom-Json if ($null -ne $AccessPathMapEntraOpsConfig.AccessPathMap -and $null -ne $AccessPathMapEntraOpsConfig.AccessPathMap.ResolveObjectIdsOutsidePrivilegedEAM) { $ResolveObjectIdsOutsidePrivilegedEAM = [bool]$AccessPathMapEntraOpsConfig.AccessPathMap.ResolveObjectIdsOutsidePrivilegedEAM } } catch { Write-Warning "Failed to read AccessPathMap settings from ${AccessPathMapConfigFilePath}: $($_.Exception.Message). Defaulting -ResolveObjectIdsOutsidePrivilegedEAM to `$true." } } } function Get-PropValue { param($Object, [string] $Name) if ($null -eq $Object) { return $null } $p = $Object.PSObject.Properties[$Name] if ($null -ne $p) { return $p.Value } return $null } function Get-AssignmentKey { param($RoleSystem, $Assignment) $instanceId = Get-PropValue $Assignment 'RoleAssignmentInstanceId' if ($instanceId) { return "$instanceId".ToUpperInvariant() } return (Get-EntraOpsRoleAssignmentInstanceId -RoleSystem "$RoleSystem" -RoleAssignment $Assignment).ToUpperInvariant() } # Export-EntraOpsPrivilegedEAMBloodHound adds pre-built Cypher "cypherSearch" helper # queries (e.g. InboundRelationships, Roles, PAWDevices) to nodes for BloodHound's own # node-info panel. They are internal/technical (raw Cypher MATCH clauses referencing # EO_ relationship kinds) and not useful in the Access Path Map detail drawer, # so they are dropped from the embedded dataset. $CypherHelperProperties = @( 'InboundRelationships', 'OutboundRelationships', 'EligibleAssignments', 'RoleAndAssignments', 'AssignmentScope', 'Roles', 'PAWDevices', 'AssociatedPrincipals', 'ScopedByRoleAssignments', 'ActiveAssignments', 'InboundIntunePermissions', 'Members' ) function ConvertTo-TierNumber { # Same normalization as the Tier Breach Analyzer: empty / unclassified => Tier 2. param($Value, [int]$Default = 2) if ($null -eq $Value) { return $Default } $s = "$Value".Trim() if ($s -eq '' -or $s -eq 'Unclassified') { return $Default } $n = 0 if (-not [int]::TryParse($s, [ref]$n)) { return $Default } if ($n -in 0, 1, 2) { return $n } return $Default } # ── Optional: enrich role nodes with descriptions from the role catalog files ── # (Classification/Classification_<System>.json), generated separately via the # Export-EntraOpsClassification*Roles.ps1 cmdlets (same optional files the # Classification Explorer consumes). Best-effort: silently skipped if absent. $roleCatalog = @{} # ROLEDEFID (upper) -> @{ description; categories } # The role catalog files live in the AzurePrivilegedIAM repository, so they are not found # under the default -RepoRoot (the EntraOps repository). Probe the explicit -RepoRoot # first, then the temporary sparse AzurePrivilegedIAM checkout used by the # Push-EntraOpsPrivilegedReporting workflow (.reporting/AzurePrivilegedIAM). $RoleCatalogRoots = @($RepoRoot, (Join-Path $RepoRoot '.reporting/AzurePrivilegedIAM')) $RoleCatalogFileNames = @( 'Classification/Classification_EntraIdDirectoryRoles.json', 'Classification/Classification_IdentityGovernance.json', 'Classification/Classification_AzureResources.json', 'Classification/Classification_DeviceManagementRoles.json' ) $RoleCatalogFiles = @( foreach ($catalogFileName in $RoleCatalogFileNames) { $catalogCandidates = @($RoleCatalogRoots | ForEach-Object { Join-Path $_ $catalogFileName }) @($catalogCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1)[0] } ) if (-not @($RoleCatalogFiles | Where-Object { $_ })) { Write-Warning "No role catalog classification files found; role node descriptions/categories stay empty. Paths tried: $(@($RoleCatalogRoots | ForEach-Object { $root = $_; $RoleCatalogFileNames | ForEach-Object { Join-Path $root $_ } }) -join ', ')" } foreach ($catalogFile in $RoleCatalogFiles) { if (-not $catalogFile) { continue } try { $catalogRoles = Get-Content -LiteralPath $catalogFile -Raw -Encoding UTF8 | ConvertFrom-Json foreach ($role in @($catalogRoles)) { if ($null -eq $role) { continue } $rid = "$(Get-PropValue $role 'RoleId')".ToUpperInvariant() if (-not $rid -or $roleCatalog.ContainsKey($rid)) { continue } $description = "$(Get-PropValue $role 'RichDescription')" # Microsoft Graph exposes multiple categories as one comma-delimited string. $categoriesRaw = @(Get-PropValue $role 'Categories') $roleCatalog[$rid] = @{ description = $description categories = @($categoriesRaw | ForEach-Object { "$_" -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } } } catch { Write-Warning "Could not parse role catalog file $($catalogFile): $($_.Exception.Message)" } } # ── Known attack path catalog ──────────────────────────────────────────────── # Cross-references roles and role actions against the same curated, documented # privilege-escalation techniques used by the Classification Explorer's "Attack # Paths" view (Reports/ClassificationExplorer/content/attack-paths/*.md). This is # what "known attack path" means throughout EntraOps Reporting - a role/role # assignment is tagged here only when it matches an actual documented technique, # not merely because it crosses an Enterprise Access Model tier boundary (that is # the separate, already-covered "tier breach" concept). function ConvertFrom-AttackPathMarkdown { param([string]$Markdown) $fm = @{} if ($Markdown -match '(?s)^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?') { foreach ($line in ($Matches[1] -split "\r?\n")) { $idx = $line.IndexOf(':') if ($idx -lt 0) { continue } $fm[$line.Substring(0, $idx).Trim()] = $line.Substring($idx + 1).Trim() } } function Get-PipeList { param([string]$SectionName) $rows = [System.Collections.Generic.List[object]]::new() if ($Markdown -match "(?s)##\s*$SectionName\s*\r?\n(.*?)(\r?\n##\s|\z)") { foreach ($line in ($Matches[1] -split "\r?\n")) { $l = $line.Trim() -replace '^[-*]\s*', '' if (-not $l) { continue } $parts = $l -split '\|' if ($parts.Count -ge 2) { $rows.Add(@{ sys = $parts[0].Trim(); value = $parts[1].Trim() }) } } } return $rows } [PSCustomObject]@{ id = $fm['id'] name = $fm['name'] severity = $fm['severity'] targetTier = $fm['targetTier'] actions = Get-PipeList -SectionName 'Actions' roles = Get-PipeList -SectionName 'Roles' } } $attackPathMeta = @{} # id (lower) -> @{ name; severity; targetTier } $attackPathByAction = @{} # "sys|loweredaction" -> List[id] $attackPathByRoleName = @{} # "sys|loweredrolename" -> List[id] $AttackPathCatalogDir = Join-Path $RepoRoot 'Reports/ClassificationExplorer/content/attack-paths' if (Test-Path -LiteralPath $AttackPathCatalogDir) { Get-ChildItem -LiteralPath $AttackPathCatalogDir -Filter '*.md' -File | ForEach-Object { try { $md = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8 $p = ConvertFrom-AttackPathMarkdown -Markdown $md if (-not $p.id) { return } $attackPathMeta[$p.id] = @{ name = $p.name; severity = $p.severity; targetTier = $p.targetTier } foreach ($a in $p.actions) { $k = "$($a.sys)|$($a.value.ToLowerInvariant())" if (-not $attackPathByAction.ContainsKey($k)) { $attackPathByAction[$k] = [System.Collections.Generic.List[string]]::new() } if ($attackPathByAction[$k] -notcontains $p.id) { $attackPathByAction[$k].Add($p.id) } } foreach ($r in $p.roles) { $k = "$($r.sys)|$($r.value.ToLowerInvariant())" if (-not $attackPathByRoleName.ContainsKey($k)) { $attackPathByRoleName[$k] = [System.Collections.Generic.List[string]]::new() } if ($attackPathByRoleName[$k] -notcontains $p.id) { $attackPathByRoleName[$k].Add($p.id) } } } catch { Write-Warning "Could not parse attack path markdown $($_.FullName): $($_.Exception.Message)" } } } else { Write-Warning "Attack path catalog not found at $AttackPathCatalogDir - roles/role assignments will not be tagged with known attack paths." } function Get-KnownAttackPathIds { param([string]$RbacSystem, [string]$RoleName, [string[]]$Actions) $ids = [System.Collections.Generic.List[string]]::new() if ($RoleName) { $k = "$RbacSystem|$($RoleName.ToLowerInvariant())" if ($attackPathByRoleName.ContainsKey($k)) { foreach ($id in $attackPathByRoleName[$k]) { if ($ids -notcontains $id) { $ids.Add($id) } } } } foreach ($a in @($Actions)) { if (-not $a) { continue } $k = "$RbacSystem|$($a.ToLowerInvariant())" if ($attackPathByAction.ContainsKey($k)) { foreach ($id in $attackPathByAction[$k]) { if ($ids -notcontains $id) { $ids.Add($id) } } } } return @($ids) } # ── 1. Read the Privileged EAM export for enrichment lookups ─────────────── $files = Get-ChildItem -Path $ImportPath -Directory | ForEach-Object { Get-ChildItem -Path $_.FullName -Filter '*.json' -File } | Sort-Object FullName if (-not $files) { Write-Warning "No Privileged EAM JSON files found under $ImportPath (expected <RbacSystem>/<RbacSystem>.json). Generating an empty dataset." } $principals = @{} # OBJECTID (upper) -> enrichment $roleDefs = @{} # ROLEDEFID (upper) -> @{ name; roleType; isPrivileged; tiernames; services; actions } $assignments = @{} # ROLEASSIGNMENTINSTANCEID (upper) -> @{ rolename; scopename; tiernames; services; actions } $azureEamObjects = [System.Collections.Generic.List[object]]::new() # raw Azure EAM objects for graph synthesis (2b) foreach ($file in @($files)) { $roleSystem = Split-Path -Leaf (Split-Path -Parent $file.FullName) try { $data = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-Warning "Could not parse $($file.FullName): $($_.Exception.Message)" continue } if ($roleSystem -eq 'Azure') { foreach ($azObj in @($data)) { if ($null -ne $azObj) { $azureEamObjects.Add($azObj) } } } foreach ($o in @($data)) { if ($null -eq $o) { continue } $oid = "$(Get-PropValue $o 'ObjectId')".ToUpperInvariant() if (-not $oid) { continue } if ([string]::IsNullOrWhiteSpace($TenantId)) { $tid = "$(Get-PropValue $o 'ObjectTenantId')" if ($tid) { $TenantId = $tid } } if (-not $principals.ContainsKey($oid)) { $tierNames = @(@(Get-PropValue $o 'Classification') | ForEach-Object { Get-PropValue $_ 'AdminTierLevelName' } | Where-Object { $_ } | Select-Object -Unique) $services = @(@(Get-PropValue $o 'Classification') | ForEach-Object { Get-PropValue $_ 'Service' } | Where-Object { $_ } | Select-Object -Unique) $objTierName = "$(Get-PropValue $o 'ObjectAdminTierLevelName')" if (-not $objTierName) { $objTierName = 'Unclassified' } # [ordered] so the enrichment copy into node properties (step 3) emits a stable # JSON key order across runs (plain hashtable key order is per-process random). $principals[$oid] = [ordered]@{ displayname = "$(Get-PropValue $o 'ObjectDisplayName')" objecttype = "$(Get-PropValue $o 'ObjectType')" objectsubtype = "$(Get-PropValue $o 'ObjectSubType')" userprincipalname = "$(Get-PropValue $o 'ObjectUserPrincipalName')" entraopsadmintierlevelname = $objTierName tiernumber = ConvertTo-TierNumber (Get-PropValue $o 'ObjectAdminTierLevel') syncsource = $(if ([bool](Get-PropValue $o 'OnPremSynchronized')) { 'Hybrid' } else { 'Cloud-Only' }) restrictedmanagementbyaadrole = [bool](Get-PropValue $o 'RestrictedManagementByAadRole') restrictedmanagementbyrag = [bool](Get-PropValue $o 'RestrictedManagementByRAG') restrictedmanagementbyrmau = [bool](Get-PropValue $o 'RestrictedManagementByRMAU') classification_tiernames = @($tierNames | ForEach-Object { "$_" }) classification_services = @($services | ForEach-Object { "$_" }) rbacsystems = [System.Collections.Generic.List[string]]::new() } } if ($principals[$oid].rbacsystems -notcontains $roleSystem) { $principals[$oid].rbacsystems.Add($roleSystem) | Out-Null } foreach ($ra in @(Get-PropValue $o 'RoleAssignments')) { if ($null -eq $ra) { continue } # Role actions ("MatchedActions"), tier(s) and service(s) classified for this # specific assignment - used to enrich both the role definition node (aggregated # across every assignment of that role) and the role assignment node (this one). $raClassifications = @(Get-PropValue $ra 'Classification') $raTierNames = @($raClassifications | ForEach-Object { Get-PropValue $_ 'AdminTierLevelName' } | Where-Object { $_ -and $_ -ne 'Unclassified' } | Select-Object -Unique) $raServices = @($raClassifications | ForEach-Object { Get-PropValue $_ 'Service' } | Where-Object { $_ -and $_ -ne 'Unclassified' } | Select-Object -Unique) $raActions = @($raClassifications | ForEach-Object { Get-PropValue $_ 'MatchedActions' } | Where-Object { $_ } | ForEach-Object { $_ } | Where-Object { $_ } | Select-Object -Unique) $rdid = "$(Get-PropValue $ra 'RoleDefinitionId')".ToUpperInvariant() if ($rdid -and -not $roleDefs.ContainsKey($rdid)) { $roleDefs[$rdid] = @{ name = "$(Get-PropValue $ra 'RoleDefinitionName')" roletype = "$(Get-PropValue $ra 'RoleType')" isprivileged = [bool](Get-PropValue $ra 'RoleIsPrivileged') rbacsystem = $roleSystem tiernames = [System.Collections.Generic.List[string]]::new() services = [System.Collections.Generic.List[string]]::new() actions = [System.Collections.Generic.List[string]]::new() } } if ($rdid) { foreach ($t in $raTierNames) { if ($roleDefs[$rdid].tiernames -notcontains "$t") { $roleDefs[$rdid].tiernames.Add("$t") | Out-Null } } foreach ($s in $raServices) { if ($roleDefs[$rdid].services -notcontains "$s") { $roleDefs[$rdid].services.Add("$s") | Out-Null } } foreach ($a in $raActions) { if ($roleDefs[$rdid].actions -notcontains "$a") { $roleDefs[$rdid].actions.Add("$a") | Out-Null } } } $raid = Get-AssignmentKey $roleSystem $ra if ($raid -and -not $assignments.ContainsKey($raid)) { $assignments[$raid] = @{ rolename = "$(Get-PropValue $ra 'RoleDefinitionName')" scopename = "$(Get-PropValue $ra 'RoleAssignmentScopeName')" rbacsystem = $roleSystem tiernames = @($raTierNames | ForEach-Object { "$_" }) services = @($raServices | ForEach-Object { "$_" }) actions = @($raActions | ForEach-Object { "$_" }) } } } } } if ([string]::IsNullOrWhiteSpace($TenantId)) { $TenantId = 'UNKNOWN-TENANT' } $TenantIdUpper = $TenantId.ToUpperInvariant() # ── 2. Build the canonical OpenGraph payload via the BloodHound exporter ─── $AvailableSystems = @(@($files) | ForEach-Object { Split-Path -Leaf (Split-Path -Parent $_.FullName) } | Where-Object { $_ -in @('EntraID', 'IdentityGovernance', 'DeviceManagement', 'ResourceApps', 'Defender') } | Select-Object -Unique) $graph = $null if ($AvailableSystems.Count -gt 0) { $TempFile = Join-Path ([System.IO.Path]::GetTempPath()) ("entraops-apa-" + [guid]::NewGuid().ToString() + ".json") $graph = & { $WhatIfPreference = $false try { Export-EntraOpsPrivilegedEAMBloodHound -TenantId $TenantId -ImportPath $ImportPath ` -RbacSystems $AvailableSystems -OutputPath $TempFile | Out-Null (Get-Content -LiteralPath $TempFile -Raw -Encoding UTF8 | ConvertFrom-Json).graph } finally { if (Test-Path -LiteralPath $TempFile) { Remove-Item -LiteralPath $TempFile -Force } } } } $graphNodes = [System.Collections.Generic.List[object]]::new() $graphEdges = [System.Collections.Generic.List[object]]::new() if ($null -ne $graph) { foreach ($n in @($graph.nodes)) { if ($null -ne $n) { $graphNodes.Add($n) } } foreach ($e in @($graph.edges)) { if ($null -ne $e) { $graphEdges.Add($e) } } } # ── 2b. Azure RBAC graph parts (synthesized - not part of the BloodHound export) ── # The BloodHound exporter does not support the Azure RBAC system, so Azure nodes and # edges are synthesized here in the exact same OpenGraph shape and naming conventions # (EO_AzureRole / EO_AzureRoleAssignment node kinds; EO_HasAzureRole, # EO_EligibleForAzureRole, EO_HasAzureRoleAssignment, EO_AzureRoleAssigned, # EO_ScopedViaResource edge kinds) and then flattened, enriched and tier-breach-flagged # by the very same pipeline below. # Ownership (EO_OwnerOf/EO_OwnedBy) and device-ownership (EO_OwnsDevice/EO_DeviceOwner) # edges are also synthesized here from the same OwnedObjects/Owners/OwnedDevices # properties the BloodHound exporter reads for the other RBAC systems, using the same # edge kinds, so Azure-sourced principals (e.g. Azure RBAC-assignable groups) show up # in the Access Path Map's "ownership" filter too. # The BloodHound integration itself is intentionally left untouched - these kinds only # exist inside the Access Path Map dataset. if ($azureEamObjects.Count -gt 0) { # Azure Tier0/Tier1 resource scope reasoning (Classification/<TenantName>/ScopeReasoning_Azure.json, # written by Update-EntraOpsClassificationControlPlaneScope) - explains why an Azure role # assignment's RoleAssignmentScopeId (and every ARM path above it) is Tier0/Tier1 resource # scope: a privileged resource at/below that scope (e.g. one hosting a system-assigned # managed identity) caused the whole ARM hierarchy above it to be included. Surfaced below # as EO_ScopedViaResource edges from the role assignment/principal to that managed # identity's own node (this edge kind is synthesized here only, like the other Azure RBAC # kinds above - the BloodHound exporter does not process the Azure RBAC system). $azureScopeReasoningDetails = Import-EntraOpsAzureScopeReasoning -RepoRoot $RepoRoot $existingNodeIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($n in $graphNodes) { [void]$existingNodeIds.Add("$(Get-PropValue $n 'id')") } $existingEdgeKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($e in $graphEdges) { $sVal = "$(Get-PropValue (Get-PropValue $e 'start') 'value')" $eVal = "$(Get-PropValue (Get-PropValue $e 'end') 'value')" [void]$existingEdgeKeys.Add("$sVal|$(Get-PropValue $e 'kind')|$eVal") } function Add-ApmGraphNode { param([string]$Id, [string[]]$Kinds, [System.Collections.IDictionary]$Properties) if ($existingNodeIds.Contains($Id)) { return } [void]$existingNodeIds.Add($Id) $graphNodes.Add([PSCustomObject]@{ id = $Id; kinds = @($Kinds); properties = [PSCustomObject]$Properties }) } function Add-ApmGraphEdge { param([string]$StartId, [string]$EndId, [string]$Kind, [System.Collections.IDictionary]$Properties) $key = "$StartId|$Kind|$EndId" if ($existingEdgeKeys.Contains($key)) { return } [void]$existingEdgeKeys.Add($key) $graphEdges.Add([PSCustomObject]@{ kind = $Kind start = [PSCustomObject]@{ value = $StartId; match_by = 'id' } end = [PSCustomObject]@{ value = $EndId; match_by = 'id' } properties = [PSCustomObject]$Properties }) } $AzurePrincipalKindMap = @{ user = 'AZUser'; group = 'AZGroup'; serviceprincipal = 'AZServicePrincipal'; device = 'AZDevice' } $azureEdgeCount = 0 foreach ($azObj in $azureEamObjects) { $azPrincipalId = "$(Get-PropValue $azObj 'ObjectId')".ToUpperInvariant() if (-not $azPrincipalId) { continue } $azKind = $AzurePrincipalKindMap["$(Get-PropValue $azObj 'ObjectType')".ToLower()] if (-not $azKind) { $azKind = 'EO_Base' } # Principal node stub - display name/classification enrichment happens in step 3 # via the $principals lookup (which already contains the Azure EAM objects). Add-ApmGraphNode -Id $azPrincipalId -Kinds @($azKind) -Properties ([ordered]@{}) # Ownership edges (EO_OwnerOf / EO_OwnedBy) and device-ownership edges # (EO_OwnsDevice / EO_DeviceOwner) - Azure is not part of the BloodHound # exporter's RbacSystems, so these have to be synthesized here from the same # OwnedObjects/Owners/OwnedDevices properties that exporter reads for the other # RBAC systems. Endpoint nodes are resolved to real principals (or a stub) by # the missing-endpoint pass in step 4 - no node is created here on purpose. $azOwnedObjectIds = @(Get-PropValue $azObj 'OwnedObjects' | Where-Object { $_ } | ForEach-Object { "$_".ToUpperInvariant() }) foreach ($ownedId in $azOwnedObjectIds) { Add-ApmGraphEdge -StartId $azPrincipalId -EndId $ownedId -Kind 'EO_OwnerOf' -Properties ([ordered]@{ rbacsystem = 'Azure' }) Add-ApmGraphEdge -StartId $ownedId -EndId $azPrincipalId -Kind 'EO_OwnedBy' -Properties ([ordered]@{ rbacsystem = 'Azure' }) $azureEdgeCount += 2 } $azOwnerIds = @(Get-PropValue $azObj 'Owners' | Where-Object { $_ } | ForEach-Object { "$_".ToUpperInvariant() }) foreach ($ownerId in $azOwnerIds) { Add-ApmGraphEdge -StartId $ownerId -EndId $azPrincipalId -Kind 'EO_OwnerOf' -Properties ([ordered]@{ rbacsystem = 'Azure' }) Add-ApmGraphEdge -StartId $azPrincipalId -EndId $ownerId -Kind 'EO_OwnedBy' -Properties ([ordered]@{ rbacsystem = 'Azure' }) $azureEdgeCount += 2 } $azOwnedDeviceIds = @(Get-PropValue $azObj 'OwnedDevices' | Where-Object { $_ } | ForEach-Object { "$_".ToUpperInvariant() }) foreach ($devId in $azOwnedDeviceIds) { Add-ApmGraphEdge -StartId $azPrincipalId -EndId $devId -Kind 'EO_OwnsDevice' -Properties ([ordered]@{ rbacsystem = 'Azure' }) Add-ApmGraphEdge -StartId $devId -EndId $azPrincipalId -Kind 'EO_DeviceOwner' -Properties ([ordered]@{ rbacsystem = 'Azure' }) $azureEdgeCount += 2 } foreach ($ra in @(Get-PropValue $azObj 'RoleAssignments')) { if ($null -eq $ra) { continue } $raid = Get-AssignmentKey $roleSystem $ra if (-not $raid) { continue } # Best (most privileged) classification for this assignment - same rule as the # BloodHound exporter's Get-BestClassification. $bestClass = @(Get-PropValue $ra 'Classification') | Where-Object { $null -ne (Get-PropValue $_ 'AdminTierLevel') } | Sort-Object { "$(Get-PropValue $_ 'AdminTierLevel')" } | Select-Object -First 1 $pimType = "$(Get-PropValue $ra 'PIMAssignmentType')" $edgeProps = [ordered]@{ admintierlevel = "$(Get-PropValue $bestClass 'AdminTierLevel')" admintierlevelname = "$(Get-PropValue $bestClass 'AdminTierLevelName')" service = "$(Get-PropValue $bestClass 'Service')" taggedby = "$(Get-PropValue $bestClass 'TaggedBy')" taggedbyrolesystem = "$(Get-PropValue $bestClass 'TaggedByRoleSystem')" roleassignmenttype = "$(Get-PropValue $ra 'RoleAssignmentType')" roleassignmentsubtype = "$(Get-PropValue $ra 'RoleAssignmentSubType')" pimassignmenttype = $pimType pimmanagedrole = [bool]((Get-PropValue $ra 'PIMManagedRole') -eq $true) roleassignmentscopeid = "$(Get-PropValue $ra 'RoleAssignmentScopeId')" roleassignmentscopename = "$(Get-PropValue $ra 'RoleAssignmentScopeName')" rbacsystem = 'Azure' } Add-ApmGraphNode -Id $raid -Kinds @('EO_AzureRoleAssignment') -Properties ([ordered]@{ name = $raid displayname = $raid rbacsystem = 'Azure' roleassignmentscopeid = "$(Get-PropValue $ra 'RoleAssignmentScopeId')" roleassignmentscopename = "$(Get-PropValue $ra 'RoleAssignmentScopeName')" roleassignmenttype = "$(Get-PropValue $ra 'RoleAssignmentType')" }) Add-ApmGraphEdge -StartId $azPrincipalId -EndId $raid -Kind 'EO_HasAzureRoleAssignment' -Properties $edgeProps $azureEdgeCount++ # EO_ScopedViaResource: why this Azure role assignment's resource scope is # Tier0/Tier1 - link to the managed identity/application whose hosting resource # caused it (see comment on $azureScopeReasoningDetails above). if ($azureScopeReasoningDetails.Count -gt 0) { $azureRoleScopeId = "$(Get-PropValue $ra 'RoleAssignmentScopeId')".Trim() foreach ($ScopeReason in (Find-EntraOpsAzureScopeReasoning -ScopeReasoningDetails $azureScopeReasoningDetails -ScopeId $azureRoleScopeId)) { $miObjectId = "$(Get-PropValue $ScopeReason 'ManagedIdentityObjectId')" if ([string]::IsNullOrEmpty($miObjectId)) { continue } $miObjectId = $miObjectId.ToUpperInvariant() $scopeReasonProps = [ordered]@{ resourcename = "$(Get-PropValue $ScopeReason 'ScopeName')" resourceid = "$(Get-PropValue $ScopeReason 'ScopeId')" eamtier = "$(Get-PropValue $ScopeReason 'EAMTier')" resultingscope = "$(Get-PropValue $ScopeReason 'ResultingScope')" reason = "$(Get-PropValue $ScopeReason 'Reason')" rbacsystem = 'Azure' } Add-ApmGraphEdge -StartId $azPrincipalId -EndId $miObjectId -Kind 'EO_ScopedViaResource' -Properties $scopeReasonProps Add-ApmGraphEdge -StartId $raid -EndId $miObjectId -Kind 'EO_ScopedViaResource' -Properties $scopeReasonProps $azureEdgeCount += 2 } } $rdid = "$(Get-PropValue $ra 'RoleDefinitionId')".ToUpperInvariant() if ($rdid) { $roleNodeId = $rdid + '@' + $TenantIdUpper $roleDefName = "$(Get-PropValue $ra 'RoleDefinitionName')" Add-ApmGraphNode -Id $roleNodeId -Kinds @('EO_AzureRole') -Properties ([ordered]@{ name = $(if ($roleDefName) { $roleDefName } else { $roleNodeId }) displayname = $(if ($roleDefName) { $roleDefName } else { $roleNodeId }) rbacsystem = 'Azure' }) $roleEdgeKind = if ($pimType -eq 'Eligible') { 'EO_EligibleForAzureRole' } else { 'EO_HasAzureRole' } Add-ApmGraphEdge -StartId $azPrincipalId -EndId $roleNodeId -Kind $roleEdgeKind -Properties $edgeProps Add-ApmGraphEdge -StartId $roleNodeId -EndId $raid -Kind 'EO_AzureRoleAssigned' -Properties $edgeProps $azureEdgeCount += 2 } } } Write-Verbose "Azure RBAC synthesized into the graph: $($azureEamObjects.Count) principal object(s), $azureEdgeCount edge(s)." } # ── 3. Flatten + enrich nodes ─────────────────────────────────────────────── $nodes = [System.Collections.Generic.List[object]]::new() $principalTierByNode = @{} # Tenant scope node (target of tenant-wide EO_ScopedTo edges). $tenantReferenced = $false foreach ($e in $graphEdges) { $endVal = Get-PropValue (Get-PropValue $e 'end') 'value' if ("$endVal" -eq $TenantIdUpper) { $tenantReferenced = $true; break } } foreach ($n in $graphNodes) { if ($null -eq $n) { continue } $id = "$(Get-PropValue $n 'id')" $props = [ordered]@{} $rawProps = Get-PropValue $n 'properties' if ($null -ne $rawProps) { foreach ($p in $rawProps.PSObject.Properties) { if ($p.Name -in $CypherHelperProperties) { continue } $props[$p.Name] = $p.Value } } $kinds = @(@(Get-PropValue $n 'kinds') | ForEach-Object { "$_" }) $primaryKind = if ($kinds.Count -gt 0) { $kinds[0] } else { 'EO_Base' } # Principal enrichment (names, sync source, restricted management, ...). if ($principals.ContainsKey($id)) { $enr = $principals[$id] foreach ($k in $enr.Keys) { if ($k -eq 'tiernumber') { continue } $v = $enr[$k] if ($v -is [System.Collections.Generic.List[string]]) { $v = @($v) } if ($null -ne $v -and -not $props.Contains($k)) { $props[$k] = $v } } if (-not $props.Contains('name')) { $props['name'] = $enr.displayname } $principalTierByNode[$id] = $enr.tiernumber } # Role definition enrichment (EntraID AZRole nodes carry no name in the export). # Node name/label always resolves to the role definition name - never a raw GUID. if ($id -match '^(.*)@' -and ($primaryKind -eq 'AZRole' -or $primaryKind -like 'EO_*Role')) { $rdid = $Matches[1] if ($roleDefs.ContainsKey($rdid)) { $rd = $roleDefs[$rdid] if (-not $props.Contains('name') -or -not $props['name']) { $props['name'] = $rd.name } if (-not $props.Contains('displayname') -or -not $props['displayname']) { $props['displayname'] = $rd.name } if (-not $props.Contains('roletype')) { $props['roletype'] = $rd.roletype } if (-not $props.Contains('isprivileged')) { $props['isprivileged'] = $rd.isprivileged } # Role actions & classification (tier/service), aggregated across every # assignment of this role, so the detail drawer can show what the role can do. if ($rd.actions.Count -gt 0 -and -not $props.Contains('matchedactions')) { $props['matchedactions'] = @($rd.actions) } if ($rd.tiernames.Count -gt 0 -and -not $props.Contains('classification_tiernames')) { $props['classification_tiernames'] = @($rd.tiernames) } if ($rd.services.Count -gt 0 -and -not $props.Contains('classification_services')) { $props['classification_services'] = @($rd.services) } # Description/categories from the optional role catalog files (best-effort). $rdidUpper = $rdid.ToUpperInvariant() if ($roleCatalog.ContainsKey($rdidUpper)) { $cat = $roleCatalog[$rdidUpper] if ($cat.description -and -not $props.Contains('description')) { $props['description'] = $cat.description } if ($cat.categories.Count -gt 0 -and -not $props.Contains('categories')) { $props['categories'] = @($cat.categories) } } # Known (documented) attack paths this role participates in - matched against # the same curated catalog the Classification Explorer's Attack Paths view uses. $rdKnownPaths = @(Get-KnownAttackPathIds -RbacSystem $rd.rbacsystem -RoleName $rd.name -Actions @($rd.actions)) if ($rdKnownPaths.Count -gt 0) { $props['knownattackpaths'] = @($rdKnownPaths) $props['knownattackpathnames'] = @($rdKnownPaths | ForEach-Object { $attackPathMeta[$_].name }) $props['knownattackpathseverity'] = if ($rdKnownPaths | Where-Object { $attackPathMeta[$_].severity -eq 'Critical' }) { 'Critical' } else { 'High' } } } } # Role assignment nodes: the BloodHound exporter names these nodes after their # (GUID) RoleAssignmentId - override with a human-readable "<role> @ <scope>" label # and add the role actions/classification for this specific assignment. if ($primaryKind -like 'EO_*RoleAssignment' -and $assignments.ContainsKey($id)) { $as = $assignments[$id] if (-not $props.Contains('roledefinitionname') -or -not $props['roledefinitionname']) { $props['roledefinitionname'] = $as.rolename } if ($as.rolename) { $assignmentLabel = if ($as.scopename) { "$($as.rolename) @ $($as.scopename)" } else { $as.rolename } $props['name'] = $assignmentLabel $props['displayname'] = $assignmentLabel } if ((-not $props.Contains('matchedactions') -or -not $props['matchedactions']) -and $as.actions.Count -gt 0) { $props['matchedactions'] = @($as.actions) } if ($as.tiernames.Count -gt 0 -and -not $props.Contains('classification_tiernames')) { $props['classification_tiernames'] = @($as.tiernames) } if ($as.services.Count -gt 0 -and -not $props.Contains('classification_services')) { $props['classification_services'] = @($as.services) } # Known (documented) attack paths this specific assignment participates in. $asKnownPaths = @(Get-KnownAttackPathIds -RbacSystem $as.rbacsystem -RoleName $as.rolename -Actions @($as.actions)) if ($asKnownPaths.Count -gt 0) { $props['knownattackpaths'] = @($asKnownPaths) $props['knownattackpathnames'] = @($asKnownPaths | ForEach-Object { $attackPathMeta[$_].name }) $props['knownattackpathseverity'] = if ($asKnownPaths | Where-Object { $attackPathMeta[$_].severity -eq 'Critical' }) { 'Critical' } else { 'High' } } } if (-not $props.Contains('name') -or -not $props['name']) { $props['name'] = $id } $nodes.Add([ordered]@{ id = $id kinds = $kinds properties = $props }) } if ($tenantReferenced) { $nodes.Add([ordered]@{ id = $TenantIdUpper kinds = @('EO_Tenant') properties = [ordered]@{ name = "Tenant ($TenantId)"; displayname = "Tenant ($TenantId)" } }) } # ── 4. Flatten edges + compute tier-breach flags ───────────────────────────── function Resolve-EndpointId { param($Endpoint) $matchBy = "$(Get-PropValue $Endpoint 'match_by')" if ($matchBy -eq 'id') { return "$(Get-PropValue $Endpoint 'value')" } # Property-matched endpoints (EO_UsesPAW / EO_PAWFor): use the matcher value. $matchers = @(Get-PropValue $Endpoint 'property_matchers') if ($matchers.Count -gt 0) { return "$(Get-PropValue $matchers[0] 'value')".ToUpperInvariant() } return '' } $BreachableEdgePattern = '^EO_(Has|EligibleFor)\w*Role(Assignment)?$' $edges = [System.Collections.Generic.List[object]]::new() $nBreach = 0 $nTier0 = 0 foreach ($e in $graphEdges) { if ($null -eq $e) { continue } $kind = "$(Get-PropValue $e 'kind')" $sourceId = Resolve-EndpointId (Get-PropValue $e 'start') $targetId = Resolve-EndpointId (Get-PropValue $e 'end') if (-not $sourceId -or -not $targetId) { continue } $props = [ordered]@{} $rawProps = Get-PropValue $e 'properties' if ($null -ne $rawProps) { foreach ($p in $rawProps.PSObject.Properties) { if ($p.Name -in $CypherHelperProperties) { continue } $props[$p.Name] = $p.Value } } # Tier-breach flags on principal -> role / role-assignment edges # (same semantics as the Tier Breach Analyzer). if ($kind -match $BreachableEdgePattern -and $principalTierByNode.ContainsKey($sourceId)) { $principalTier = $principalTierByNode[$sourceId] $serviceTier = ConvertTo-TierNumber $props['admintierlevel'] $isBreach = $principalTier -gt $serviceTier $isTier0 = ($serviceTier -eq 0 -and $principalTier -gt 0) $props['principaltier'] = $principalTier $props['servicetier'] = $serviceTier $props['tierbreach'] = $isBreach $props['tier0breach'] = $isTier0 if ($isBreach) { $nBreach++ } if ($isTier0) { $nTier0++ } } $edges.Add([ordered]@{ kind = $kind source = $sourceId target = $targetId properties = $props }) } # ── 4b. Resolve edge endpoints that have no graph node ("unresolved objects") ── # Edges can reference objects the EAM export carries only by id (owned/PAW devices, # work accounts, sponsors, Intune scope group members, ...). The frontend previously # dropped those edges from the graph and showed "(unresolved object)" in the drawer. # Display names for devices and Intune scope groups are recovered from the persisted # group -> device member mapping written by Update-EntraOpsClassificationControlPlaneScope # (Classification/<tenant>/DeviceManagement_ScopeGroupDeviceMembers.json); everything # else gets an explicit placeholder node (unresolved = true) so the edge stays visible. $deviceNameLookup = @{} # UPPER object id -> @{ name; kind } $classificationRoot = Join-Path $RepoRoot 'Classification' if (Test-Path -LiteralPath $classificationRoot) { foreach ($mapFile in @(Get-ChildItem -Path $classificationRoot -Filter 'DeviceManagement_ScopeGroupDeviceMembers.json' -Recurse -File -ErrorAction SilentlyContinue)) { try { $mapData = Get-Content -LiteralPath $mapFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-Warning "Could not parse $($mapFile.FullName): $($_.Exception.Message)" continue } $groupMembers = Get-PropValue $mapData 'groupDeviceMembers' if ($null -ne $groupMembers) { foreach ($gp in @($groupMembers.PSObject.Properties)) { $gid = "$($gp.Name)".ToUpperInvariant() $gName = "$(Get-PropValue $gp.Value 'displayName')" if ($gid -and $gName -and -not $deviceNameLookup.ContainsKey($gid)) { $deviceNameLookup[$gid] = @{ name = $gName; kind = 'AZGroup' } } foreach ($dm in @(Get-PropValue $gp.Value 'deviceMembers')) { $did = "$(Get-PropValue $dm 'id')".ToUpperInvariant() $dName = "$(Get-PropValue $dm 'displayName')" if ($did -and $dName -and -not $deviceNameLookup.ContainsKey($did)) { $deviceNameLookup[$did] = @{ name = $dName; kind = 'AZDevice' } } } } } foreach ($dev in @(Get-PropValue $mapData 'allClassifiedDevices')) { $did = "$(Get-PropValue $dev 'id')".ToUpperInvariant() $dName = "$(Get-PropValue $dev 'displayName')" if ($did -and $dName -and -not $deviceNameLookup.ContainsKey($did)) { $deviceNameLookup[$did] = @{ name = $dName; kind = 'AZDevice' } } } } } $knownNodeIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($n in $nodes) { [void]$knownNodeIds.Add("$($n.id)") } $PrincipalStubKindMap = @{ user = 'AZUser'; group = 'AZGroup'; serviceprincipal = 'AZServicePrincipal'; device = 'AZDevice' } $nResolvedEndpoints = 0 $nStubbedEndpoints = 0 $nGraphResolvedEndpoints = 0 $graphResolutionDurationSeconds = 0 $missingEndpointIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($e in $edges) { foreach ($endpointId in @($e.source, $e.target)) { if (-not $knownNodeIds.Contains($endpointId)) { [void]$missingEndpointIds.Add($endpointId) } } } # ── Optional: resolve remaining unresolved endpoints (not part of the Privileged EAM # export or the Intune device map above) via Microsoft Graph. These are typically # non-privileged objects only referenced through OwnedObjects/Owners/AssociatedPawDevice/ # AssociatedWorkAccount/Sponsors (e.g. a privileged account's standard work account, or a # group/application owned by a privileged principal). Opt-in via -ResolveObjectIdsOutsidePrivilegedEAM # / EntraOpsConfig.json "AccessPathMap.ResolveObjectIdsOutsidePrivilegedEAM" since it requires an # active Graph connection and adds network round-trips. $graphResolvedLookup = @{} # UPPER object id -> @{ name; kind } if ($ResolveObjectIdsOutsidePrivilegedEAM -and $missingEndpointIds.Count -gt 0) { $GuidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' $idsToResolve = @($missingEndpointIds | Where-Object { $u = $_.ToUpperInvariant() ($_ -match $GuidPattern) -and -not $deviceNameLookup.ContainsKey($u) -and -not $principals.ContainsKey($u) }) if ($idsToResolve.Count -gt 0) { Write-Verbose "Resolving $($idsToResolve.Count) edge endpoint object id(s) outside the Privileged EAM export via Microsoft Graph..." $GraphBatchSize = 999 # directoryObjects/getByIds accepts up to 1000 ids per request $nGraphResolveFailures = 0 $graphResolutionStopwatch = [System.Diagnostics.Stopwatch]::StartNew() for ($i = 0; $i -lt $idsToResolve.Count; $i += $GraphBatchSize) { $idBatch = $idsToResolve[$i..([Math]::Min($i + $GraphBatchSize - 1, $idsToResolve.Count - 1))] $getByIdsBody = @{ ids = @($idBatch) types = @('user', 'group', 'servicePrincipal', 'device') } | ConvertTo-Json try { $resolvedObjects = @(Invoke-EntraOpsMsGraphQuery -Method POST -Uri '/v1.0/directoryObjects/getByIds' -Body $getByIdsBody -OutputType PSObject) foreach ($resolvedObject in $resolvedObjects) { $resolvedId = "$(Get-PropValue $resolvedObject 'id')".ToUpperInvariant() if ([string]::IsNullOrEmpty($resolvedId) -or $graphResolvedLookup.ContainsKey($resolvedId)) { continue } $odataType = "$(Get-PropValue $resolvedObject '@odata.type')" $resolvedKind = switch -Regex ($odataType) { 'user$' { 'AZUser' } 'group$' { 'AZGroup' } 'servicePrincipal$' { 'AZServicePrincipal' } 'device$' { 'AZDevice' } default { 'EO_Base' } } $resolvedName = "$(Get-PropValue $resolvedObject 'displayName')" if ([string]::IsNullOrEmpty($resolvedName)) { $resolvedName = $resolvedId } $graphResolvedLookup[$resolvedId] = @{ name = $resolvedName; kind = $resolvedKind } } } catch { $nGraphResolveFailures += $idBatch.Count Write-Warning "Failed to resolve $($idBatch.Count) object id(s) outside the Privileged EAM export via Microsoft Graph: $($_.Exception.Message)" } } $graphResolutionStopwatch.Stop() $graphResolutionDurationSeconds = [Math]::Round($graphResolutionStopwatch.Elapsed.TotalSeconds, 2) if ($nGraphResolveFailures -gt 0) { Write-Warning "$nGraphResolveFailures object id(s) outside the Privileged EAM export could not be resolved via Microsoft Graph and will be shown as unresolved placeholders." } if ($graphResolutionDurationSeconds -ge 30) { Write-Warning "Access Path Map resolved $($idsToResolve.Count) outside-EAM endpoint id(s) via Microsoft Graph in $graphResolutionDurationSeconds second(s). If this materially slows report generation, set AccessPathMap.ResolveObjectIdsOutsidePrivilegedEAM to false in EntraOpsConfig.json; unresolved endpoints remain visible as placeholders." } } } foreach ($missingId in $missingEndpointIds) { $upperId = $missingId.ToUpperInvariant() $stub = $null if ($deviceNameLookup.ContainsKey($upperId)) { # Resolved via the persisted Intune scope group -> device member mapping. $hit = $deviceNameLookup[$upperId] $stub = [ordered]@{ id = $missingId kinds = @($hit.kind) properties = [ordered]@{ name = $hit.name; displayname = $hit.name; objectid = $upperId } } $nResolvedEndpoints++ } elseif ($principals.ContainsKey($upperId)) { # Known privileged object from the EAM export that never got its own graph node. $enr = $principals[$upperId] $stubKind = $PrincipalStubKindMap["$($enr.objecttype)".ToLower()] if (-not $stubKind) { $stubKind = 'EO_Base' } $stubName = if ($enr.displayname) { $enr.displayname } else { $missingId } $stub = [ordered]@{ id = $missingId kinds = @($stubKind) properties = [ordered]@{ name = $stubName; displayname = $stubName; objectid = $upperId; entraopsadmintierlevelname = $enr.entraopsadmintierlevelname } } $nResolvedEndpoints++ } elseif ($graphResolvedLookup.ContainsKey($upperId)) { # Resolved via Microsoft Graph (-ResolveObjectIdsOutsidePrivilegedEAM): a non-privileged # object (e.g. a standard work account, or a group/application owned by a privileged # principal) that only appears as an OwnedObjects/Owners/AssociatedPawDevice/ # AssociatedWorkAccount/Sponsors reference, not as a privileged object in the EAM export. $hit = $graphResolvedLookup[$upperId] $stub = [ordered]@{ id = $missingId kinds = @($hit.kind) properties = [ordered]@{ name = $hit.name; displayname = $hit.name; objectid = $upperId } } $nGraphResolvedEndpoints++ } else { # Still unknown - keep the edge visible with an explicit unresolved placeholder. $stub = [ordered]@{ id = $missingId kinds = @('EO_Base') properties = [ordered]@{ name = $missingId; displayname = $missingId; unresolved = $true } } $nStubbedEndpoints++ } $nodes.Add($stub) [void]$knownNodeIds.Add($missingId) } # ── 5. Write dataset ───────────────────────────────────────────────────────── $repoRootFull = (Resolve-Path -LiteralPath $RepoRoot).Path $tenantName = Get-EntraOpsReportingTenantName -RepoRoot $RepoRoot $payload = [ordered]@{ tenantName = $tenantName generatedFrom = @(@($files) | ForEach-Object { if ($_.FullName.StartsWith($repoRootFull)) { $_.FullName.Substring($repoRootFull.Length).TrimStart('\', '/') -replace '\\', '/' } else { $_.Name } }) tenantId = $TenantIdUpper nodes = $nodes edges = $edges } $json = $payload | ConvertTo-Json -Depth 12 -Compress $content = "// Auto-generated by New-EntraOpsAccessPathMapData - do not edit by hand.`n" + "window.ENTRAOPS_APM_DATA = $json;`n" if ($PSCmdlet.ShouldProcess($OutFile, 'Write Access Path Map dataset')) { Save-EntraOpsReportDataFile -Content $content -LiteralPath $OutFile Write-Host "Graph nodes: $($nodes.Count)" Write-Host "Graph edges: $($edges.Count)" Write-Host "Tier breach edges: $nBreach" Write-Host "Tier 0 breaches: $nTier0" Write-Host "Endpoints resolved via Intune device map / EAM: $nResolvedEndpoints" if ($ResolveObjectIdsOutsidePrivilegedEAM) { Write-Host "Endpoints resolved via Microsoft Graph (outside Privileged EAM): $nGraphResolvedEndpoints" Write-Host "Microsoft Graph endpoint resolution duration: $graphResolutionDurationSeconds second(s)" } Write-Host "Endpoints kept as unresolved placeholders: $nStubbedEndpoints" Write-Host "Wrote $OutFile" } if ($PassThru) { $payload } } # SIG # Begin signature block # MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDmlgInVF479q3d # q3Z+VGy3tVwQyVzseaQLmLEIL6WI/KCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j # 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 # ARUwLwYJKoZIhvcNAQkEMSIEICLpc0oigXmuOfv/3XWS/cTdSbLuE+/aMGkPX/HV # zX1YMA0GCSqGSIb3DQEBAQUABIIBgKIqgKRNH6qhH7rFWS2Z4VOnAJHDQUwyQS/C # cAgOU7XlIO25jH0IVBCyEtn1NQRqxo1kyujEZgqasMfpds4dBnZqoVZoSjM0fMUj # DKJnnqUKOT64OuuF3kVqVLZMvSkIQVgll/caL4bns4TEdDjw9q5SQOo3KI9wyG59 # VFqaDUhuD5Ncmp86oQ6onkJ6KPiSOCLzFFbh+a0URMIZVDGRgg8uMx6jddko4kyA # XwTBL4FD892EkvImWSOoYI5YMlgKXf9LE8zHh21PC8oXBDvRgal70Y3EukerZn8z # XuPip2B7cUZyTDvTbOFNFWBgYC+3KtZRDhG8HwUXaKN8PszFgQd929O5rEO7GTIh # jbL05GQHL240ZnLGvneqirm3VS45farP6Bu96hgcFVkTGdsMJ2T08cefyRVOJIKc # OP6WkJwr+kuT+ODfFSVfVYAUMpM5g4EDJxZ/SwcrCDGQNKJBlAcquH/qvySZKYaF # GW41js8rQRYY9j1Xmgpoe0k1en8dEKGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID # 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0 # ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ # KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD # MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQxNTBaMDcG # CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY # 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDBHml8kbutqlnpopf0RCWSiqnSqRoNxrrEo # cktJkJ/ya7qlUA56rDLf7tMgXtcStR4wgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ # MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh # MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0 # dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI # hvcNAQEBBQAEggIAmabfBPelRU+Wcl1D29eXH1IByz1ql06wHMSRAMl9SeO1D43w # xqKAUCJAMqhafsKEmf7GxpeYKlj3EKy3mdCyD9XLbaaDEB0W8sA7e3iKVmStmLoT # 5gg5DdgClffloZTI0uazh0RbMtZeNZAlnXBc40DOFHtzvoY40cP2PK4gEbX2sAvh # kkjyaFw4dPs6hlrK79dMZEsFwPd04Zn/TmHgzb+gM59L5C4x5Dhg9ljIaU+NpcZz # aycyi8p8vJfEie4FI9vXGx6unf1h1t66WU4KhlzIHzJ2Sj4hWJkMUZKC9GWFSB/p # YIL5uRcg81AVlJfntQXr4JlQo9f16/tpOWVzD7wpgWdEDHMDzeVtA6WlShrVASvD # SMw+0XKTnApkOtLQQcm/xRChqxsU9+nFyj6Ro8YvnGHxIrEax9tP/6pyiPvfx9Wc # pADzxvycR6VuMy11lLer8eM/C8DDo1uVIxgDEB7ceH9O1LSz6qP4x/uNwc+FfHF2 # itjq9z9pXswMTpKTB0TS5XMf2A2Y+w3MczzDi0nw3veATtZuK6wMJCyDj3mz95r7 # Ei7N5UPQiqbR/2b6e2m6nYCD4rlXboy9MUdIlPwLTXSaPSnihsZ7jY34GW5o2hrE # 72VdiqXHGJluBfuTUk76ooYr/PXE5zJqe9xrkPHS9wovQtOe6Hc2R46vPjs= # SIG # End signature block |