modules/Azure/Discovery/Private/InvokeCIEMGraphComputedEdgeBuild.ps1
|
function InvokeCIEMGraphComputedEdgeBuild { <# .SYNOPSIS Derives computed graph edges from resource properties. .DESCRIPTION Analyzes ARM resources, Entra resources, and collected relationships to derive computed edges that represent inferred relationships not directly collected from APIs: - HasRole: identity -> scope (from role assignments) - InheritedRole: group member -> scope (expanded from group role assignments) - HasManagedIdentity: ARM resource -> managed identity service principal - AttachedTo: NIC -> VM - HasPublicIP: NIC -> Public IP - InSubnet: NIC -> VNet (with subnet_id in properties) - AllowsInbound: Internet -> NSG (aggregated open ports per NSG) - ContainedIn: resource -> subscription All computed edges are saved with computed=1. .PARAMETER ArmResources Array of ARM resource objects. .PARAMETER EntraResources Array of Entra resource objects. .PARAMETER Relationships Array of collected relationship objects (used for group membership expansion). .PARAMETER Connection SQLite connection for transaction support. Pass $null for standalone operations. .PARAMETER CollectedAt ISO 8601 timestamp for the collection time. .OUTPUTS [int] Total number of computed edges created. #> [CmdletBinding()] [OutputType([int])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$ArmResources, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$EntraResources, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Relationships, [Parameter(Mandatory)] [AllowNull()] [object]$Connection, [Parameter(Mandatory)] [string]$CollectedAt ) $ErrorActionPreference = 'Stop' $edgeCount = 0 # Node existence cache to avoid repeated DB queries $nodeExistsCache = @{} # Build Save-CIEMGraphEdge splat base $baseSplat = @{ Computed = 1; CollectedAt = $CollectedAt } if ($Connection) { $baseSplat.Connection = $Connection } # ===== Helper: check if node exists in graph ===== # Must use same $Connection to see uncommitted nodes within a transaction function NodeExists([string]$nodeId) { if (-not $nodeId) { return $false } if ($nodeExistsCache.ContainsKey($nodeId)) { return $nodeExistsCache[$nodeId] } $fkParams = @{ Query = "SELECT 1 FROM graph_nodes WHERE id = @id"; Parameters = @{ id = $nodeId } } if ($Connection) { $fkParams.Connection = $Connection } $exists = (@(Invoke-CIEMQuery @fkParams).Count -gt 0) $nodeExistsCache[$nodeId] = $exists $exists } # ===== Helper: save edge with FK-safe error handling ===== # Role assignment scopes may reference resources with case mismatches vs graph_nodes IDs. # Log and skip FK failures rather than crashing the entire discovery run. function SaveEdgeSafe([hashtable]$splat) { try { Save-CIEMGraphEdge @splat return $true } catch { if ($_.Exception.Message -match 'FOREIGN KEY' -or ($_.Exception.InnerException -and $_.Exception.InnerException.Message -match 'FOREIGN KEY')) { Write-CIEMLog "FK constraint: skipping edge $($splat.Kind) $($splat.SourceId) -> $($splat.TargetId)" -Severity WARNING -Component 'GraphBuilder' return $false } throw } } # ===== 1. Build lookups from ARM resources ===== # Role definition lookup: roleDefId -> { RoleName, PermissionsJson } $roleDefLookup = @{} foreach ($r in $ArmResources) { if ($r.Type -eq 'microsoft.authorization/roledefinitions' -and $r.Properties) { try { $props = $r.Properties | ConvertFrom-Json -ErrorAction Stop } catch { continue } if ($props) { $roleDefLookup[$r.Id] = @{ RoleName = $props.roleName PermissionsJson = if ($props.permissions) { $props.permissions | ConvertTo-Json -Depth 10 -Compress } else { $null } } } } } # Display name lookup: entraId -> displayName $displayNameLookup = @{} foreach ($e in $EntraResources) { if ($e.Id -and $e.DisplayName) { $displayNameLookup[$e.Id] = $e.DisplayName } } # Transitive membership lookup: groupId -> list of { Id, Type } $groupMembersLookup = @{} foreach ($rel in $Relationships) { if ($rel.Relationship -eq 'transitive_member_of') { $groupId = $rel.TargetId if (-not $groupMembersLookup.ContainsKey($groupId)) { $groupMembersLookup[$groupId] = [System.Collections.Generic.List[object]]::new() } $groupMembersLookup[$groupId].Add(@{ Id = $rel.SourceId; Type = $rel.SourceType }) } } # ===== 2. HasRole + InheritedRole edges ===== $roleAssignments = @($ArmResources | Where-Object { $_.Type -eq 'microsoft.authorization/roleassignments' -and $_.Properties }) foreach ($ra in $roleAssignments) { try { $raProps = $ra.Properties | ConvertFrom-Json -ErrorAction Stop } catch { continue } if (-not $raProps) { continue } $principalId = $raProps.principalId $principalType = $raProps.principalType $roleDefinitionId = $raProps.roleDefinitionId $scope = $raProps.scope if (-not $principalId -or -not $roleDefinitionId -or -not $scope) { continue } $roleDef = $roleDefLookup[$roleDefinitionId] $roleName = if ($roleDef) { $roleDef.RoleName } else { $null } $permissionsJson = if ($roleDef) { $roleDef.PermissionsJson } else { $null } # TestCIEMAzurePrivilegedRole requires non-empty RoleName; default to 'Unknown' when missing $roleNameForCheck = if ($roleName) { $roleName } else { 'Unknown' } $isPrivileged = TestCIEMAzurePrivilegedRole -RoleName $roleNameForCheck -PermissionsJson $permissionsJson $edgePropsHash = @{ role_name = $roleName role_definition_id = $roleDefinitionId permissions_json = $permissionsJson privileged = $isPrivileged principal_type = $principalType } $edgePropsJson = $edgePropsHash | ConvertTo-Json -Depth 3 -Compress # Direct HasRole edge if ((NodeExists $principalId) -and (NodeExists $scope)) { $splat = $baseSplat.Clone() $splat.SourceId = $principalId $splat.TargetId = $scope $splat.Kind = 'HasRole' $splat.Properties = $edgePropsJson if (SaveEdgeSafe $splat) { $edgeCount++ } } # InheritedRole: expand group memberships if ($principalType -eq 'Group') { $members = $groupMembersLookup[$principalId] if ($members) { foreach ($member in $members) { if ((NodeExists $member.Id) -and (NodeExists $scope)) { $inheritedPropsHash = @{ role_name = $roleName role_definition_id = $roleDefinitionId permissions_json = $permissionsJson privileged = $isPrivileged principal_type = $member.Type inherited_from = $principalId inherited_from_name = $displayNameLookup[$principalId] } $inheritedPropsJson = $inheritedPropsHash | ConvertTo-Json -Depth 3 -Compress $splat = $baseSplat.Clone() $splat.SourceId = $member.Id $splat.TargetId = $scope $splat.Kind = 'InheritedRole' $splat.Properties = $inheritedPropsJson if (SaveEdgeSafe $splat) { $edgeCount++ } } } } } } # ===== 3. HasManagedIdentity edges ===== foreach ($r in $ArmResources) { if (-not $r.Identity) { continue } try { $identityJson = $r.Identity | ConvertFrom-Json -ErrorAction Stop $principalId = $identityJson.principalId if ($principalId -and (NodeExists $r.Id) -and (NodeExists $principalId)) { $splat = $baseSplat.Clone() $splat.SourceId = $r.Id $splat.TargetId = $principalId $splat.Kind = 'HasManagedIdentity' if (SaveEdgeSafe $splat) { $edgeCount++ } } } catch { Write-CIEMLog -Message "HasManagedIdentity edge build failed for resource $($r.Id): $_" -Severity WARNING -Component 'GraphBuilder' } } # ===== 4. Network topology edges (NIC -> VM, NIC -> PIP, NIC -> Subnet) ===== $nics = @($ArmResources | Where-Object { $_.Type -eq 'microsoft.network/networkinterfaces' -and $_.Properties }) foreach ($nic in $nics) { try { $nicProps = $nic.Properties | ConvertFrom-Json -ErrorAction Stop } catch { continue } # AttachedTo: NIC -> VM $vmId = $null if ($nicProps.virtualMachine -and $nicProps.virtualMachine.id) { $vmId = $nicProps.virtualMachine.id if ((NodeExists $nic.Id) -and (NodeExists $vmId)) { $splat = $baseSplat.Clone() $splat.SourceId = $nic.Id $splat.TargetId = $vmId $splat.Kind = 'AttachedTo' if (SaveEdgeSafe $splat) { $edgeCount++ } } } # AttachedTo: NSG -> VM (derived from NIC having both NSG and VM references) if ($vmId -and $nicProps.networkSecurityGroup -and $nicProps.networkSecurityGroup.id) { $nsgId = $nicProps.networkSecurityGroup.id if ((NodeExists $nsgId) -and (NodeExists $vmId)) { $splat = $baseSplat.Clone() $splat.SourceId = $nsgId $splat.TargetId = $vmId $splat.Kind = 'AttachedTo' if (SaveEdgeSafe $splat) { $edgeCount++ } } } # HasPublicIP + InSubnet from ipConfigurations if ($nicProps.ipConfigurations) { foreach ($ipConfig in $nicProps.ipConfigurations) { $ipProps = if ($ipConfig.properties) { $ipConfig.properties } else { $ipConfig } # HasPublicIP: NIC -> PublicIP if ($ipProps.publicIPAddress -and $ipProps.publicIPAddress.id) { $pipId = $ipProps.publicIPAddress.id if ((NodeExists $nic.Id) -and (NodeExists $pipId)) { $splat = $baseSplat.Clone() $splat.SourceId = $nic.Id $splat.TargetId = $pipId $splat.Kind = 'HasPublicIP' if (SaveEdgeSafe $splat) { $edgeCount++ } } } # InSubnet: NIC -> VNet (extract VNet ID from subnet path) if ($ipProps.subnet -and $ipProps.subnet.id) { $subnetId = $ipProps.subnet.id # VNet ID = everything before /subnets/ $vnetId = $subnetId -replace '/subnets/[^/]+$', '' if ($vnetId -ne $subnetId -and (NodeExists $nic.Id) -and (NodeExists $vnetId)) { $splat = $baseSplat.Clone() $splat.SourceId = $nic.Id $splat.TargetId = $vnetId $splat.Kind = 'InSubnet' $splat.Properties = @{ subnet_id = $subnetId } | ConvertTo-Json -Compress if (SaveEdgeSafe $splat) { $edgeCount++ } } } } } } # ===== 5. AllowsInbound edges: Internet -> NSG (aggregated per NSG) ===== $nsgs = @($ArmResources | Where-Object { $_.Type -eq 'microsoft.network/networksecuritygroups' -and $_.Properties }) foreach ($nsg in $nsgs) { try { $nsgProps = $nsg.Properties | ConvertFrom-Json -ErrorAction Stop } catch { continue } $allRules = @() if ($nsgProps.securityRules) { $allRules += $nsgProps.securityRules } if ($nsgProps.defaultSecurityRules) { $allRules += $nsgProps.defaultSecurityRules } # Collect all inbound-allow-from-internet rules $openPorts = [System.Collections.Generic.List[object]]::new() foreach ($rule in $allRules) { $ruleProps = if ($rule.properties) { $rule.properties } else { $rule } if ($ruleProps.direction -eq 'Inbound' -and $ruleProps.access -eq 'Allow' -and $ruleProps.sourceAddressPrefix -in @('*', '0.0.0.0/0', 'Internet')) { # Azure uses destinationPortRange (singular) for single-port rules and # destinationPortRanges (plural array) for multi-port rules (singular is '' when plural is used) $portEntries = @() if ($ruleProps.destinationPortRanges -and $ruleProps.destinationPortRanges.Count -gt 0) { $portEntries = $ruleProps.destinationPortRanges } elseif ($ruleProps.destinationPortRange) { $portEntries = @($ruleProps.destinationPortRange) } foreach ($portEntry in $portEntries) { $openPorts.Add(@{ port = $portEntry protocol = $ruleProps.protocol rule_name = $rule.name }) } } } if ($openPorts.Count -gt 0 -and (NodeExists '__internet__') -and (NodeExists $nsg.Id)) { $edgePropsJson = @{ open_ports = @($openPorts) } | ConvertTo-Json -Depth 5 -Compress $splat = $baseSplat.Clone() $splat.SourceId = '__internet__' $splat.TargetId = $nsg.Id $splat.Kind = 'AllowsInbound' $splat.Properties = $edgePropsJson if (SaveEdgeSafe $splat) { $edgeCount++ } } } # ===== 6. ContainedIn: resource -> subscription ===== # Build subscription node ID lookup: subscription_id -> node id $subNodeIds = @{} foreach ($r in $ArmResources) { if ($r.Type -eq 'microsoft.resources/subscriptions' -and $r.SubscriptionId) { $subNodeIds[$r.SubscriptionId] = $r.Id } } foreach ($r in $ArmResources) { if (-not $r.SubscriptionId) { continue } # Skip subscription-self and authorization resources if ($r.Type -eq 'microsoft.resources/subscriptions') { continue } if ($r.Type -match '^microsoft\.authorization/') { continue } $subNodeId = $subNodeIds[$r.SubscriptionId] if (-not $subNodeId) { continue } if ((NodeExists $r.Id) -and (NodeExists $subNodeId)) { $splat = $baseSplat.Clone() $splat.SourceId = $r.Id $splat.TargetId = $subNodeId $splat.Kind = 'ContainedIn' if (SaveEdgeSafe $splat) { $edgeCount++ } } } Write-CIEMLog "Graph computed edge build: $edgeCount computed edges created" -Component 'GraphBuilder' $edgeCount } |