modules/normalizers/Normalize-ConditionalAccessGraph.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Normalizer for the conditional-access-graph wrapper (graph mapping R1). .DESCRIPTION Converts the v1 wrapper envelope from Invoke-ConditionalAccessGraph.ps1 into: * v2 FindingRow objects (via New-FindingRow) for each policy-risk finding emitted by the wrapper. * v3 Edge objects (via New-Edge) for AppliesTo / Excludes relationships between the ConditionalAccessPolicy and the User / Group / Application / NamedLocation entities it gates. The normalizer returns a flat array of v2 FindingRow objects. When the caller passes -EdgeCollector, AppliesTo / Excludes edges for the User / Group / Application / NamedLocation entities the policy gates are appended to that list for the orchestrator to drain into the EntityStore. This matches the flat-array + edge-collector contract used by the rest of the graph-mapping family. Domain=IdentityGraph, Pillar=Identity. #> [CmdletBinding()] param () Set-StrictMode -Version Latest . "$PSScriptRoot\..\shared\Schema.ps1" . "$PSScriptRoot\..\shared\Canonicalize.ps1" . "$PSScriptRoot\..\shared\Sanitize.ps1" # Microsoft Graph "All" sentinels are not entity IDs; skip them when # emitting edges so we do not clutter the graph with synthetic vertices. $script:CaSentinels = @('All', 'None', 'GuestsOrExternalUsers') function Get-PropertyValue { param ([object]$Obj, [string]$Name, [object]$Default = $null) if ($null -eq $Obj) { return $Default } if (-not $Obj.PSObject.Properties[$Name]) { return $Default } $v = $Obj.PSObject.Properties[$Name].Value if ($null -eq $v) { return $Default } return $v } function Test-IsGuid { param ([string] $Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $false } # Use the framework parser so mixed-case / uppercase Graph IDs are # accepted; a hand-rolled regex would reject valid GUIDs. $g = [guid]::Empty return [guid]::TryParse($Value, [ref]$g) } function ConvertTo-CaTargetEntity { <# .SYNOPSIS Resolve a CA condition value (user id, group id, app id, named location id) to a canonical EntityId + EntityType pair. .DESCRIPTION Sentinels (All, None, GuestsOrExternalUsers) and non-GUID strings are filtered out so they do not produce malformed edges. Each category routes through ConvertTo-CanonicalEntityId so the IDs match what the rest of the pipeline emits. Role-template IDs are recognised but not emitted as edges yet -- the schema does not yet have a Role EntityType, and collapsing them onto User would misclassify directory roles as users (see PR #1012 review). #> param ( [Parameter(Mandatory)] [string] $Value, [Parameter(Mandatory)] [ValidateSet('User','Group','Role','Application','NamedLocation')] [string] $Category ) if ($script:CaSentinels -contains $Value) { return $null } if (-not (Test-IsGuid $Value)) { return $null } try { switch ($Category) { 'User' { $c = ConvertTo-CanonicalEntityId -RawId $Value -EntityType 'User' return [PSCustomObject]@{ EntityId = $c.CanonicalId; EntityType = 'User'; Platform = 'Entra' } } 'Application' { $c = ConvertTo-CanonicalEntityId -RawId $Value -EntityType 'Application' return [PSCustomObject]@{ EntityId = $c.CanonicalId; EntityType = 'Application'; Platform = 'Entra' } } 'NamedLocation' { $c = ConvertTo-CanonicalEntityId -RawId $Value -EntityType 'NamedLocation' return [PSCustomObject]@{ EntityId = $c.CanonicalId; EntityType = 'NamedLocation'; Platform = 'Entra' } } 'Group' { # Schema does not yet have a first-class Group EntityType, but # the rest of the graph (e.g. Invoke-IdentityGraphExpansion's # MemberOf edges) already represents Entra groups with a # `group:{guid}` target token. Mirror that convention so CA # group edges line up with the existing graph instead of # misclassifying groups as User vertices. $lower = $Value.ToLowerInvariant() return [PSCustomObject]@{ EntityId = "group:$lower"; EntityType = $null; Platform = 'Entra' } } 'Role' { # Skip role-template IDs until the schema gains a Role # EntityType. Collapsing onto User would misclassify # directory roles (e.g. Global Administrator) as user # principals and pollute the graph. return $null } } } catch { return $null } return $null } function New-CaPolicyEdges { <# .SYNOPSIS Emit AppliesTo / Excludes edges for a single CA policy projection. #> param ( [Parameter(Mandatory)] [PSCustomObject] $Projection, [Parameter(Mandatory)] [string] $PolicyEntityId ) $edges = [System.Collections.Generic.List[PSCustomObject]]::new() $includeMap = [ordered]@{ IncludeUsers = 'User' IncludeGroups = 'Group' IncludeRoles = 'Role' IncludeApps = 'Application' IncludeLocs = 'NamedLocation' } $excludeMap = [ordered]@{ ExcludeUsers = 'User' ExcludeGroups = 'Group' ExcludeRoles = 'Role' ExcludeApps = 'Application' ExcludeLocs = 'NamedLocation' } foreach ($field in $includeMap.Keys) { $cat = $includeMap[$field] foreach ($val in @(Get-PropertyValue -Obj $Projection -Name $field -Default @())) { $target = ConvertTo-CaTargetEntity -Value ([string]$val) -Category $cat if (-not $target) { continue } $edge = New-Edge -Source $PolicyEntityId -Target $target.EntityId ` -Relation 'AppliesTo' -Platform 'Entra' ` -DiscoveredBy 'conditional-access-graph' ` -Confidence 'Confirmed' ` -Properties @{ Category = $cat; PolicyState = $Projection.State } if ($edge) { $edges.Add($edge) | Out-Null } } } foreach ($field in $excludeMap.Keys) { $cat = $excludeMap[$field] foreach ($val in @(Get-PropertyValue -Obj $Projection -Name $field -Default @())) { $target = ConvertTo-CaTargetEntity -Value ([string]$val) -Category $cat if (-not $target) { continue } $edge = New-Edge -Source $PolicyEntityId -Target $target.EntityId ` -Relation 'Excludes' -Platform 'Entra' ` -DiscoveredBy 'conditional-access-graph' ` -Confidence 'Confirmed' ` -Properties @{ Category = $cat; PolicyState = $Projection.State } if ($edge) { $edges.Add($edge) | Out-Null } } } return @($edges) } function Normalize-ConditionalAccessGraph { <# .DESCRIPTION Returns a flat array of v2 FindingRow objects. When the caller passes -EdgeCollector (the orchestrator's $normalizerEdgeCollectorFM list), AppliesTo / Excludes edges are appended to it for the EntityStore drain. Test suites that want the edge list back without supplying a collector should pass an empty `[System.Collections.Generic.List[psobject]]::new()`. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject] $ToolResult, [System.Collections.Generic.List[psobject]] $EdgeCollector ) if (-not $ToolResult -or $ToolResult.Status -ne 'Success') { return @() } $runId = [guid]::NewGuid().ToString() $rows = [System.Collections.Generic.List[PSCustomObject]]::new() $projections = @(Get-PropertyValue -Obj $ToolResult -Name 'Policies' -Default @()) $rawFindings = @(Get-PropertyValue -Obj $ToolResult -Name 'Findings' -Default @()) # FindingRows. Each finding is anchored to the policy's canonical entity # id so the report and the EntityStore can pivot on the policy. foreach ($f in $rawFindings) { if (-not $f) { continue } $policyId = [string](Get-PropertyValue -Obj $f -Name 'ResourceId' -Default '') if ([string]::IsNullOrWhiteSpace($policyId)) { continue } try { $canon = ConvertTo-CanonicalEntityId -RawId $policyId -EntityType 'ConditionalAccessPolicy' } catch { continue } $rawSev = [string](Get-PropertyValue -Obj $f -Name 'Severity' -Default 'Medium') $severity = switch -Regex ($rawSev.ToLowerInvariant()) { 'critical' { 'Critical' } 'high' { 'High' } 'medium|moderate' { 'Medium' } 'low' { 'Low' } 'info' { 'Info' } default { 'Medium' } } $detail = Remove-Credentials ([string](Get-PropertyValue -Obj $f -Name 'Detail' -Default '')) $row = New-FindingRow ` -Id ([string](Get-PropertyValue -Obj $f -Name 'Id' -Default ([guid]::NewGuid().ToString()))) ` -Source 'conditional-access-graph' ` -EntityId $canon.CanonicalId ` -EntityType 'ConditionalAccessPolicy' ` -Platform 'Entra' ` -Title ([string](Get-PropertyValue -Obj $f -Name 'Title' -Default 'Conditional Access policy gap')) ` -Compliant $false ` -ProvenanceRunId $runId ` -Category ([string](Get-PropertyValue -Obj $f -Name 'Category' -Default 'Identity Graph')) ` -Severity $severity ` -Detail $detail ` -Remediation ([string](Get-PropertyValue -Obj $f -Name 'Remediation' -Default '')) ` -ResourceId $policyId ` -RuleId ([string](Get-PropertyValue -Obj $f -Name 'RuleId' -Default '')) ` -Pillar ([string](Get-PropertyValue -Obj $f -Name 'Pillar' -Default 'Identity')) ` -Impact ([string](Get-PropertyValue -Obj $f -Name 'Impact' -Default 'Medium')) ` -Effort ([string](Get-PropertyValue -Obj $f -Name 'Effort' -Default 'Low')) ` -DeepLinkUrl ([string](Get-PropertyValue -Obj $f -Name 'DeepLinkUrl' -Default '')) ` -Confidence 'Confirmed' ` -BaselineTags @("ca:state:$([string](Get-PropertyValue -Obj $f -Name 'PolicyState' -Default ''))") if ($null -ne $row) { $rows.Add($row) | Out-Null } } # Edges. Iterate the projections and emit AppliesTo / Excludes edges # for every targeted user / group / app / location. foreach ($proj in $projections) { if (-not $proj) { continue } $policyRaw = [string](Get-PropertyValue -Obj $proj -Name 'Id' -Default '') if ([string]::IsNullOrWhiteSpace($policyRaw)) { continue } try { $canon = ConvertTo-CanonicalEntityId -RawId $policyRaw -EntityType 'ConditionalAccessPolicy' } catch { continue } foreach ($e in (New-CaPolicyEdges -Projection $proj -PolicyEntityId $canon.CanonicalId)) { if ($null -ne $EdgeCollector) { $EdgeCollector.Add($e) | Out-Null } } } return @($rows) } |