modules/shared/EntityStore.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS In-memory entity and finding store with spill-to-disk support. .DESCRIPTION Maintains deduplicated entities and findings, merging duplicates per the schema v2 dedup contract. Supports disk spill when memory limits are hit. #> [CmdletBinding()] param () Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sanitizePath = Join-Path $PSScriptRoot 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param ([string] $Text) return $Text } } $script:SeverityRank = @{ Critical = 5 High = 4 Medium = 3 Low = 2 Info = 1 } function Get-SeverityRank { param ([string] $Severity) if (-not $Severity) { return 0 } return $script:SeverityRank[$Severity] ?? 0 } function Get-NonEmptyValue { param ([object] $Current, [object] $Incoming) if ($null -ne $Current) { if ($Current -is [string]) { if (-not [string]::IsNullOrWhiteSpace($Current)) { return $Current } } elseif ($Current -is [System.Collections.IEnumerable]) { if (@($Current).Count -gt 0) { return $Current } } else { return $Current } } return $Incoming } function Merge-UniqueByKey { param ( [object[]] $Existing, [object[]] $Incoming, [scriptblock] $KeySelector ) $result = [System.Collections.Generic.List[object]]::new() $lookup = @{} foreach ($item in @($Existing)) { if (-not $item) { continue } $key = & $KeySelector $item if (-not $key) { continue } if (-not $lookup.ContainsKey($key)) { $lookup[$key] = $item $result.Add($item) } } foreach ($item in @($Incoming)) { if (-not $item) { continue } $key = & $KeySelector $item if (-not $key) { continue } if (-not $lookup.ContainsKey($key)) { $lookup[$key] = $item $result.Add($item) } } return $result.ToArray() } function Merge-FrameworksUnion { <# .SYNOPSIS Deduplicate a list of Schema 2.2 framework hashtables by (kind, controlId) tuple. .DESCRIPTION Frameworks is the Schema 2.2 first-class field carrying compliance-framework membership. Each entry is a hashtable / PSCustomObject with at minimum `kind` (e.g. 'CIS', 'NIST', 'MITRE', 'EIDSCA') and `controlId` (e.g. '1.1.1', 'CA-7', 'TA0001'). When two wrappers tag the same finding with overlapping framework metadata (e.g. PSRule + Maester both report CIS 1.1.1), the union is collapsed to a single entry per `(kind, controlId)` tuple. The first occurrence wins so callers can prefer existing/local data over incoming. Comparison of both `kind` and `controlId` is case-sensitive. #> param ( [object[]] $Existing, [object[]] $Incoming ) return Merge-UniqueByKey -Existing $Existing -Incoming $Incoming -KeySelector { param ($item) $kind = Get-ObjectPropertyValue -Object $item -PropertyName 'kind' if ([string]::IsNullOrWhiteSpace([string]$kind)) { $kind = Get-ObjectPropertyValue -Object $item -PropertyName 'Kind' } if ([string]::IsNullOrWhiteSpace([string]$kind)) { $kind = Get-ObjectPropertyValue -Object $item -PropertyName 'Name' } if ([string]::IsNullOrWhiteSpace([string]$kind)) { $kind = Get-ObjectPropertyValue -Object $item -PropertyName 'name' } if ([string]::IsNullOrWhiteSpace([string]$kind)) { $kind = Get-ObjectPropertyValue -Object $item -PropertyName 'framework' } $controlId = Get-ObjectPropertyValue -Object $item -PropertyName 'controlId' if ([string]::IsNullOrWhiteSpace([string]$controlId)) { $controlId = Get-ObjectPropertyValue -Object $item -PropertyName 'ControlId' } if ([string]::IsNullOrWhiteSpace([string]$controlId)) { $controlId = Get-ObjectPropertyValue -Object $item -PropertyName 'id' } if ([string]::IsNullOrWhiteSpace([string]$controlId)) { $controlId = Get-ObjectPropertyValue -Object $item -PropertyName 'Id' } if ($null -eq $kind -and $item -is [System.Collections.IDictionary]) { $kind = ($item['kind'] ?? $item['Kind'] ?? $item['Name'] ?? $item['name'] ?? $item['framework']) } if ($null -eq $controlId -and $item -is [System.Collections.IDictionary]) { $controlId = ($item['controlId'] ?? $item['ControlId'] ?? $item['id'] ?? $item['Id']) } if ([string]::IsNullOrWhiteSpace([string]$controlId)) { $controls = Get-ObjectPropertyValue -Object $item -PropertyName 'Controls' if ($controls) { $firstControl = @($controls | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -First 1) if ($firstControl.Count -gt 0) { $controlId = [string]$firstControl[0] } } } if ([string]::IsNullOrWhiteSpace([string]$kind) -or [string]::IsNullOrWhiteSpace([string]$controlId)) { return $null } return "$kind|$controlId" } } function Merge-BaselineTagsUnion { <# .SYNOPSIS Deduplicate a list of Schema 2.2 baseline tag strings, case-sensitive. .DESCRIPTION BaselineTags carry release-channel / lifecycle annotations such as 'release:GA', 'release:preview', 'release:deprecated', 'baseline:cis-1.4'. Tags are case-sensitive (preview != PREVIEW) so two wrappers that disagree on case will round-trip as distinct tags rather than being silently merged. #> param ( [string[]] $Existing, [string[]] $Incoming ) $result = [System.Collections.Generic.List[string]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($tag in @($Existing) + @($Incoming)) { if ([string]::IsNullOrWhiteSpace($tag)) { continue } if ($seen.Add($tag)) { $result.Add($tag) } } return $result.ToArray() } function Merge-MissingDimensions { param ( [string[]] $Existing, [string[]] $Incoming ) if ($Existing -and $Incoming) { return @($Existing | Where-Object { $Incoming -contains $_ }) } if (-not $Existing -and $Incoming) { return @($Incoming) } return $Existing } function Get-ObjectPropertyValue { param ( [object] $Object, [string] $PropertyName, [object] $Default = $null ) if ($null -eq $Object) { return $Default } $property = $Object.PSObject.Properties[$PropertyName] if ($null -eq $property) { return $Default } return $property.Value } function New-StoreEntity { param ( [pscustomobject] $EntityStub, [string] $EntityId, [string] $EntityType, [string] $Platform ) $observations = [System.Collections.Generic.List[pscustomobject]]::new() if ($EntityStub -and $EntityStub.Observations) { foreach ($obs in @($EntityStub.Observations)) { if ($obs) { $observations.Add($obs) } } } $displayName = $null $subscriptionId = $null $subscriptionName = $null $resourceGroup = $null $managementGroupPath = $null $externalIds = $null $worstSeverity = $null $compliantCount = 0 $nonCompliantCount = 0 $sources = @() $monthlyCost = $null $currency = $null $costTrend = $null $frameworks = $null $baselineTags = $null $controls = $null $policies = $null $correlations = $null $confidence = $null $missingDimensions = $null if ($null -ne $EntityStub) { $displayName = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'DisplayName' $subscriptionId = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'SubscriptionId' $subscriptionName = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'SubscriptionName' $resourceGroup = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'ResourceGroup' $managementGroupPath = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'ManagementGroupPath' $externalIds = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'ExternalIds' $worstSeverity = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'WorstSeverity' $entityCompliantCount = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'CompliantCount' if ($null -ne $entityCompliantCount) { $compliantCount = $entityCompliantCount } $entityNonCompliantCount = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'NonCompliantCount' if ($null -ne $entityNonCompliantCount) { $nonCompliantCount = $entityNonCompliantCount } $entitySources = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'Sources' if ($null -ne $entitySources) { $sources = $entitySources } $monthlyCost = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'MonthlyCost' $currency = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'Currency' $costTrend = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'CostTrend' $frameworks = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'Frameworks' $baselineTags = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'BaselineTags' $controls = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'Controls' $policies = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'Policies' $correlations = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'Correlations' $confidence = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'Confidence' $missingDimensions = Get-ObjectPropertyValue -Object $EntityStub -PropertyName 'MissingDimensions' } [PSCustomObject]@{ EntityId = $EntityId EntityType = $EntityType Platform = $Platform DisplayName = $displayName SubscriptionId = $subscriptionId SubscriptionName = $subscriptionName ResourceGroup = $resourceGroup ManagementGroupPath = $managementGroupPath ExternalIds = $externalIds Observations = $observations WorstSeverity = $worstSeverity CompliantCount = $compliantCount NonCompliantCount = $nonCompliantCount Sources = $sources MonthlyCost = $monthlyCost Currency = $currency CostTrend = $costTrend Frameworks = $frameworks BaselineTags = $baselineTags Controls = $controls Policies = $policies Correlations = $correlations Confidence = $confidence MissingDimensions = $missingDimensions } } class EntityStore { [hashtable] $Entities [System.Collections.Generic.List[pscustomobject]] $Findings [hashtable] $FindingIndex [hashtable] $Edges [int] $MaxEntitiesInMemory = 50000 [int] $SpillFileCount = 0 [string] $OutputPath EntityStore([int] $MaxEntitiesInMemory = 50000, [string] $OutputPath = $(Join-Path (Get-Location) 'output')) { $this.Entities = @{} $this.Findings = [System.Collections.Generic.List[pscustomobject]]::new() $this.FindingIndex = @{} $this.Edges = @{} if ($env:AZURE_ANALYZER_MAX_ENTITIES) { $parsed = 0 if ([int]::TryParse($env:AZURE_ANALYZER_MAX_ENTITIES, [ref]$parsed) -and $parsed -gt 0) { $this.MaxEntitiesInMemory = $parsed } else { $this.MaxEntitiesInMemory = $MaxEntitiesInMemory } } else { $this.MaxEntitiesInMemory = $MaxEntitiesInMemory } $this.OutputPath = $OutputPath if (-not (Test-Path $this.OutputPath)) { $null = New-Item -ItemType Directory -Path $this.OutputPath -Force } } [string] GetEntityKey([string] $Platform, [string] $EntityType, [string] $EntityId) { return "$Platform|$EntityType|$EntityId" } [string] GetFindingKey([pscustomobject] $Finding) { if (-not $Finding -or [string]::IsNullOrWhiteSpace([string]$Finding.EntityId)) { return $null } return "$($Finding.Source)|$($Finding.EntityId)|$($Finding.Title)|$($Finding.Compliant)" } [void] MergeFinding([pscustomobject] $Target, [pscustomobject] $Incoming) { if (-not $Target -or -not $Incoming) { return } if ((Get-SeverityRank $Incoming.Severity) -gt (Get-SeverityRank $Target.Severity)) { $Target.Severity = $Incoming.Severity } $targetDetailLength = 0 if (($null -ne $Target) -and ($null -ne $Target.Detail)) { $targetDetailLength = $Target.Detail.Length } if ($Incoming.Detail -and ($Incoming.Detail.Length -gt $targetDetailLength)) { $Target.Detail = $Incoming.Detail } $targetRemediationLength = 0 if (($null -ne $Target) -and ($null -ne $Target.Remediation)) { $targetRemediationLength = $Target.Remediation.Length } if ($Incoming.Remediation -and ($Incoming.Remediation.Length -gt $targetRemediationLength)) { $Target.Remediation = $Incoming.Remediation } if (-not $Target.LearnMoreUrl -and $Incoming.LearnMoreUrl) { $Target.LearnMoreUrl = $Incoming.LearnMoreUrl } if (-not $Target.DeepLinkUrl -and $Incoming.DeepLinkUrl) { $Target.DeepLinkUrl = $Incoming.DeepLinkUrl } $incomingFrameworks = if ($Incoming.PSObject.Properties['Frameworks']) { @($Incoming.Frameworks) } else { @() } if (@($incomingFrameworks).Count -gt 0) { $existingFrameworks = if ($Target.PSObject.Properties['Frameworks']) { @($Target.Frameworks) } else { @() } if (-not $Target.PSObject.Properties['Frameworks']) { $Target | Add-Member -NotePropertyName 'Frameworks' -NotePropertyValue @() -Force } $Target.Frameworks = Merge-FrameworksUnion -Existing $existingFrameworks -Incoming $incomingFrameworks } $incomingBaselineTags = if ($Incoming.PSObject.Properties['BaselineTags']) { @($Incoming.BaselineTags) } else { @() } if (@($incomingBaselineTags).Count -gt 0) { $existingBaselineTags = if ($Target.PSObject.Properties['BaselineTags']) { @($Target.BaselineTags) } else { @() } if (-not $Target.PSObject.Properties['BaselineTags']) { $Target | Add-Member -NotePropertyName 'BaselineTags' -NotePropertyValue @() -Force } $Target.BaselineTags = Merge-BaselineTagsUnion -Existing $existingBaselineTags -Incoming $incomingBaselineTags } foreach ($scalar in @('Pillar', 'Impact', 'Effort', 'DeepLinkUrl', 'ToolVersion')) { if ((-not $Target.PSObject.Properties[$scalar] -or [string]::IsNullOrWhiteSpace([string]$Target.$scalar)) -and $Incoming.PSObject.Properties[$scalar] -and -not [string]::IsNullOrWhiteSpace([string]$Incoming.$scalar)) { if (-not $Target.PSObject.Properties[$scalar]) { $Target | Add-Member -NotePropertyName $scalar -NotePropertyValue $Incoming.$scalar } else { $Target.$scalar = $Incoming.$scalar } } } if ($Incoming.PSObject.Properties['ScoreDelta'] -and $null -ne $Incoming.ScoreDelta -and (-not $Target.PSObject.Properties['ScoreDelta'] -or $null -eq $Target.ScoreDelta)) { if (-not $Target.PSObject.Properties['ScoreDelta']) { $Target | Add-Member -NotePropertyName 'ScoreDelta' -NotePropertyValue $Incoming.ScoreDelta } else { $Target.ScoreDelta = $Incoming.ScoreDelta } } if ($Incoming.PSObject.Properties['Frameworks']) { $mergedFrameworks = Merge-FrameworksUnion -Existing @($Target.Frameworks) -Incoming @($Incoming.Frameworks) if ($mergedFrameworks.Count -gt 0) { if (-not $Target.PSObject.Properties['Frameworks']) { $Target | Add-Member -NotePropertyName 'Frameworks' -NotePropertyValue $mergedFrameworks } else { $Target.Frameworks = $mergedFrameworks } } } if ($Incoming.PSObject.Properties['BaselineTags']) { $mergedTags = Merge-BaselineTagsUnion -Existing @($Target.BaselineTags) -Incoming @($Incoming.BaselineTags) if ($mergedTags.Count -gt 0) { if (-not $Target.PSObject.Properties['BaselineTags']) { $Target | Add-Member -NotePropertyName 'BaselineTags' -NotePropertyValue $mergedTags } else { $Target.BaselineTags = $mergedTags } } } foreach ($arrayField in @('EvidenceUris', 'EntityRefs', 'MitreTactics', 'MitreTechniques')) { if (-not $Incoming.PSObject.Properties[$arrayField]) { continue } $mergedValues = Merge-UniqueByKey -Existing @($Target.$arrayField) -Incoming @($Incoming.$arrayField) -KeySelector { param ($item) [string]$item } if ($mergedValues.Count -gt 0) { if (-not $Target.PSObject.Properties[$arrayField]) { $Target | Add-Member -NotePropertyName $arrayField -NotePropertyValue $mergedValues } else { $Target.$arrayField = $mergedValues } } } if ($Incoming.Provenance) { if (-not $Target.Provenance) { $Target.Provenance = $Incoming.Provenance } else { $existingStamp = $null $incomingStamp = $null if ($Target.Provenance.Timestamp) { $existingStamp = Get-Date $Target.Provenance.Timestamp -ErrorAction SilentlyContinue } if ($Incoming.Provenance.Timestamp) { $incomingStamp = Get-Date $Incoming.Provenance.Timestamp -ErrorAction SilentlyContinue } if (-not $existingStamp -or ($incomingStamp -and $incomingStamp -lt $existingStamp)) { $Target.Provenance = $Incoming.Provenance } } } } [void] UpdateEntityAggregates([pscustomobject] $Entity, [pscustomobject] $Finding) { if (-not $Entity -or -not $Finding) { return } if (-not $Entity.Sources) { $Entity.Sources = @() } if ($Finding.Source -and -not ($Entity.Sources -contains $Finding.Source)) { $Entity.Sources += $Finding.Source } if ($Finding.Severity -and ((Get-SeverityRank $Finding.Severity) -gt (Get-SeverityRank $Entity.WorstSeverity))) { $Entity.WorstSeverity = $Finding.Severity } if ($Finding.Compliant -is [bool]) { if ($Finding.Compliant) { $Entity.CompliantCount++ } else { $Entity.NonCompliantCount++ } } } [void] AddFinding([pscustomobject] $Finding) { if (-not $Finding) { throw "Finding cannot be null." } $findingKey = $this.GetFindingKey($Finding) $targetFinding = $Finding if ($findingKey -and $this.FindingIndex.ContainsKey($findingKey)) { $existing = $this.FindingIndex[$findingKey] $this.MergeFinding($existing, $Finding) $targetFinding = $existing } else { $this.Findings.Add($Finding) if ($findingKey) { $this.FindingIndex[$findingKey] = $Finding } } if (-not [string]::IsNullOrWhiteSpace([string]$Finding.EntityId)) { $entityKey = $this.GetEntityKey($Finding.Platform, $Finding.EntityType, $Finding.EntityId) $entity = $this.Entities[$entityKey] if (-not $entity) { $entity = New-StoreEntity -EntityStub $null -EntityId $Finding.EntityId -EntityType $Finding.EntityType -Platform $Finding.Platform $this.Entities[$entityKey] = $entity } try { $this.MergeEntityMetadata([pscustomobject]@{ EntityId = $Finding.EntityId EntityType = $Finding.EntityType Platform = $Finding.Platform DisplayName = $(if ($Finding.PSObject.Properties['DisplayName']) { $Finding.DisplayName } else { $null }) SubscriptionId = $(if ($Finding.PSObject.Properties['SubscriptionId']) { $Finding.SubscriptionId } else { $null }) SubscriptionName = $(if ($Finding.PSObject.Properties['SubscriptionName']) { $Finding.SubscriptionName } else { $null }) ResourceGroup = $(if ($Finding.PSObject.Properties['ResourceGroup']) { $Finding.ResourceGroup } else { $null }) ManagementGroupPath = $(if ($Finding.PSObject.Properties['ManagementGroupPath']) { $Finding.ManagementGroupPath } else { $null }) Frameworks = $(if ($Finding.PSObject.Properties['Frameworks']) { $Finding.Frameworks } else { $null }) BaselineTags = $(if ($Finding.PSObject.Properties['BaselineTags']) { $Finding.BaselineTags } else { $null }) Controls = $(if ($Finding.PSObject.Properties['Controls']) { $Finding.Controls } else { $null }) Confidence = $(if ($Finding.PSObject.Properties['Confidence']) { $Finding.Confidence } else { $null }) MissingDimensions = $(if ($Finding.PSObject.Properties['MissingDimensions']) { $Finding.MissingDimensions } else { $null }) }) } catch { $exceptionMessage = [string]$_.Exception.Message if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) { $exceptionMessage = Remove-Credentials -Text $exceptionMessage } Write-Verbose "EntityStore metadata merge skipped for $($Finding.EntityId): $exceptionMessage" } if ($entity.Observations -isnot [System.Collections.Generic.List[pscustomobject]]) { $observations = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($obs in @($entity.Observations)) { if ($obs) { $observations.Add($obs) } } $entity.Observations = $observations } if (-not $entity.Observations.Contains($targetFinding)) { $entity.Observations.Add($targetFinding) $this.UpdateEntityAggregates($entity, $targetFinding) } } if (($this.Entities.Count + $this.Findings.Count) -gt $this.MaxEntitiesInMemory) { $this.SpillToDisk() } } [void] MergeEntityMetadata([pscustomobject] $EntityStub) { if (-not $EntityStub) { throw "EntityStub cannot be null." } $entityId = $EntityStub.EntityId ?? $EntityStub.CanonicalId if (-not $entityId) { throw "EntityStub must include EntityId or CanonicalId." } if (-not $EntityStub.EntityType) { throw "EntityStub must include EntityType." } if (-not $EntityStub.Platform) { throw "EntityStub must include Platform." } $entityKey = $this.GetEntityKey($EntityStub.Platform, $EntityStub.EntityType, $entityId) $entity = $this.Entities[$entityKey] if (-not $entity) { $entity = New-StoreEntity -EntityStub $EntityStub -EntityId $entityId -EntityType $EntityStub.EntityType -Platform $EntityStub.Platform $this.Entities[$entityKey] = $entity return } $entity.DisplayName = Get-NonEmptyValue $entity.DisplayName $EntityStub.DisplayName $entity.SubscriptionName = Get-NonEmptyValue $entity.SubscriptionName $EntityStub.SubscriptionName $entity.ManagementGroupPath = Get-NonEmptyValue $entity.ManagementGroupPath $EntityStub.ManagementGroupPath $entity.SubscriptionId = Get-NonEmptyValue $entity.SubscriptionId $EntityStub.SubscriptionId $entity.ResourceGroup = Get-NonEmptyValue $entity.ResourceGroup $EntityStub.ResourceGroup $entity.ExternalIds = Merge-UniqueByKey -Existing $entity.ExternalIds -Incoming $EntityStub.ExternalIds -KeySelector { param ($item) "$($item.Platform)|$($item.Id)" } $entity.Frameworks = Merge-FrameworksUnion -Existing @($entity.Frameworks) -Incoming @($EntityStub.Frameworks) $entity.BaselineTags = Merge-BaselineTagsUnion -Existing @($entity.BaselineTags) -Incoming @($EntityStub.BaselineTags) $entity.Policies = Merge-UniqueByKey -Existing $entity.Policies -Incoming $EntityStub.Policies -KeySelector { param ($item) "$($item.PolicyName)|$($item.AssignmentScope)" } if ($EntityStub.MonthlyCost -ne $null) { $entity.MonthlyCost = $EntityStub.MonthlyCost } if ($EntityStub.Currency) { $entity.Currency = $EntityStub.Currency } if ($EntityStub.CostTrend) { $entity.CostTrend = $EntityStub.CostTrend } $entity.MissingDimensions = Merge-MissingDimensions -Existing $entity.MissingDimensions -Incoming $EntityStub.MissingDimensions } [void] SpillToDisk() { $batch = $this.SpillFileCount $entitiesPath = Join-Path $this.OutputPath ("entities-partial-{0}.json" -f $batch) $findingsPath = Join-Path $this.OutputPath ("findings-partial-{0}.json" -f $batch) $entitiesJson = $this.Entities.Values | ConvertTo-Json -Depth 30 $findingsJson = $this.Findings | ConvertTo-Json -Depth 30 $entitiesJson = Remove-Credentials $entitiesJson $findingsJson = Remove-Credentials $findingsJson if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) { $entitiesJson = Remove-Credentials $entitiesJson $findingsJson = Remove-Credentials $findingsJson } Set-Content -Path $entitiesPath -Value $entitiesJson -Encoding UTF8 Set-Content -Path $findingsPath -Value $findingsJson -Encoding UTF8 $this.Entities = @{} $this.Findings = [System.Collections.Generic.List[pscustomobject]]::new() $this.FindingIndex = @{} $this.SpillFileCount++ Write-Warning "EntityStore exceeded $($this.MaxEntitiesInMemory) combined records, spilling batch $batch to disk. Consider scoping to fewer subscriptions." } [pscustomobject[]] GetEntities() { $merged = @{} foreach ($entity in $this.Entities.Values) { $key = $this.GetEntityKey($entity.Platform, $entity.EntityType, $entity.EntityId) $merged[$key] = $entity } if ($this.SpillFileCount -gt 0) { $files = Get-ChildItem -Path $this.OutputPath -Filter 'entities-partial-*.json' -ErrorAction SilentlyContinue | Sort-Object Name foreach ($file in $files) { $partial = Get-Content -Raw $file.FullName | ConvertFrom-Json -ErrorAction Stop foreach ($entity in @($partial)) { $key = $this.GetEntityKey($entity.Platform, $entity.EntityType, $entity.EntityId) if (-not $merged.ContainsKey($key)) { $merged[$key] = $entity } else { $existing = $merged[$key] if ($existing.Observations -is [System.Collections.Generic.List[pscustomobject]]) { foreach ($obs in @($entity.Observations)) { if ($obs) { $existing.Observations.Add($obs) } } } else { $existing.Observations = @($existing.Observations) + @($entity.Observations) } $existing.Sources = Merge-UniqueByKey -Existing $existing.Sources -Incoming $entity.Sources -KeySelector { param ($item) $item } if ((Get-SeverityRank $entity.WorstSeverity) -gt (Get-SeverityRank $existing.WorstSeverity)) { $existing.WorstSeverity = $entity.WorstSeverity } $existing.CompliantCount = [int]($existing.CompliantCount ?? 0) + [int]($entity.CompliantCount ?? 0) $existing.NonCompliantCount = [int]($existing.NonCompliantCount ?? 0) + [int]($entity.NonCompliantCount ?? 0) } } } } return $merged.Values } [pscustomobject[]] GetFindings() { $merged = [System.Collections.Generic.List[pscustomobject]]::new() $index = @{} $allFindings = @() if ($this.SpillFileCount -gt 0) { $files = Get-ChildItem -Path $this.OutputPath -Filter 'findings-partial-*.json' -ErrorAction SilentlyContinue | Sort-Object Name foreach ($file in $files) { $allFindings += @(Get-Content -Raw $file.FullName | ConvertFrom-Json -ErrorAction Stop) } } $allFindings += @($this.Findings) foreach ($finding in @($allFindings)) { $key = $this.GetFindingKey($finding) if ($key -and $index.ContainsKey($key)) { $this.MergeFinding($index[$key], $finding) } else { $merged.Add($finding) if ($key) { $index[$key] = $finding } } } return $merged.ToArray() } [void] CleanupSpillFiles() { if ($this.SpillFileCount -eq 0) { return } Get-ChildItem -Path $this.OutputPath -Filter 'entities-partial-*.json' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue Get-ChildItem -Path $this.OutputPath -Filter 'findings-partial-*.json' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue $this.SpillFileCount = 0 } [void] AddEdge([pscustomobject] $Edge) { <# Add an edge to the store. Dedup is keyed on the deterministic EdgeId from New-Edge. Repeated discovery rounds collapse cleanly. The most recent discovery wins for Properties / DiscoveredAt so re-runs refresh metadata without duplicating relationships. #> if (-not $Edge) { throw "Edge cannot be null." } if (-not $Edge.PSObject.Properties['EdgeId'] -or [string]::IsNullOrWhiteSpace([string]$Edge.EdgeId)) { throw "Edge must have a non-empty EdgeId." } $key = [string]$Edge.EdgeId $this.Edges[$key] = $Edge } [pscustomobject[]] GetEdges() { return @($this.Edges.Values) } } function Export-Entities { <# .SYNOPSIS Export merged entities from an EntityStore. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [EntityStore] $Store ) return $Store.GetEntities() } function Export-Findings { <# .SYNOPSIS Export merged findings from an EntityStore. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [EntityStore] $Store ) return $Store.GetFindings() } function Export-Edges { <# .SYNOPSIS Export merged edges from an EntityStore. .DESCRIPTION Returns the v3.1 edge array. Always returns an array (possibly empty) so callers can JSON-encode without null checks. Uses the unary comma operator so PowerShell does not unwrap an empty enumerable to $null. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [EntityStore] $Store ) $edges = $Store.GetEdges() if ($null -eq $edges) { return ,@() } return ,@($edges) } function Import-EntitiesFile { <# .SYNOPSIS Read entities.json with back-compat for the legacy bare-array shape. .DESCRIPTION v3.0 entities.json was a bare JSON array of entity objects. v3.1 wraps it in an object: { SchemaVersion, Entities, Edges }. This helper accepts either shape and always returns a PSCustomObject with .SchemaVersion, .Entities (array), .Edges (array). .PARAMETER Path Path to the entities.json file. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path ) if (-not (Test-Path $Path)) { throw "Entities file not found: $Path" } $raw = Get-Content -Raw -Path $Path | ConvertFrom-Json -ErrorAction Stop if ($null -eq $raw) { return [PSCustomObject]@{ SchemaVersion = '3.0' Entities = @() Edges = @() } } # v3.1+ shape: object with Entities/Edges properties if ($raw -is [PSCustomObject] -and $raw.PSObject.Properties['Entities']) { $schema = if ($raw.PSObject.Properties['SchemaVersion'] -and $raw.SchemaVersion) { [string]$raw.SchemaVersion } else { '3.1' } $entities = @($raw.Entities) $edges = if ($raw.PSObject.Properties['Edges'] -and $raw.Edges) { @($raw.Edges) } else { @() } return [PSCustomObject]@{ SchemaVersion = $schema Entities = $entities Edges = $edges } } # Legacy v3.0 shape: bare array return [PSCustomObject]@{ SchemaVersion = '3.0' Entities = @($raw) Edges = @() } } function Export-Results { <# .SYNOPSIS Export v1-compatible flat findings array. .DESCRIPTION Returns the same shape as results.json. Cleans up spill files after merging when spill files exist. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [EntityStore] $Store ) $results = $Store.GetFindings() $Store.CleanupSpillFiles() return $results } function New-PortfolioSeverityCounts { [CmdletBinding()] param ( [object[]] $Findings ) $relevant = @($Findings | Where-Object { $_ -and $_.PSObject.Properties['Compliant'] -and -not $_.Compliant }) [pscustomobject]@{ Critical = @($relevant | Where-Object { $_.Severity -eq 'Critical' }).Count High = @($relevant | Where-Object { $_.Severity -eq 'High' }).Count Medium = @($relevant | Where-Object { $_.Severity -eq 'Medium' }).Count Low = @($relevant | Where-Object { $_.Severity -eq 'Low' }).Count Info = @($relevant | Where-Object { $_.Severity -eq 'Info' }).Count } } function Resolve-SubscriptionId { [CmdletBinding()] param ( [Parameter(Mandatory)] [object] $InputObject ) $subscriptionId = Get-ObjectPropertyValue -Object $InputObject -PropertyName 'SubscriptionId' if (-not [string]::IsNullOrWhiteSpace([string]$subscriptionId)) { return [string]$subscriptionId } foreach ($propertyName in @('ResourceId', 'EntityId')) { $textValue = Get-ObjectPropertyValue -Object $InputObject -PropertyName $propertyName if ([string]::IsNullOrWhiteSpace([string]$textValue)) { continue } if ([string]$textValue -match '(?i)/subscriptions/([0-9a-f-]{36})') { return $Matches[1].ToLowerInvariant() } } return $null } function Get-SubscriptionBucketKey { [CmdletBinding()] param ( [Parameter(Mandatory)] [object] $InputObject ) $subscriptionId = Resolve-SubscriptionId -InputObject $InputObject if (-not [string]::IsNullOrWhiteSpace([string]$subscriptionId)) { return "id::$($subscriptionId.ToLowerInvariant())" } $subscriptionName = [string](Get-ObjectPropertyValue -Object $InputObject -PropertyName 'SubscriptionName' -Default '') if (-not [string]::IsNullOrWhiteSpace($subscriptionName)) { return "name::$subscriptionName" } return $null } function Get-TopPortfolioEntities { [CmdletBinding()] param ( [object[]] $Entities, [int] $MaxCount = 5 ) $sorted = @( $Entities | Where-Object { $_ } | Sort-Object ` @{ Expression = { Get-SeverityRank (Get-ObjectPropertyValue -Object $_ -PropertyName 'WorstSeverity') }; Descending = $true }, ` @{ Expression = { [int](Get-ObjectPropertyValue -Object $_ -PropertyName 'NonCompliantCount' -Default 0) }; Descending = $true }, ` @{ Expression = { [double](Get-ObjectPropertyValue -Object $_ -PropertyName 'MonthlyCost' -Default 0) }; Descending = $true }, ` @{ Expression = { [string](Get-ObjectPropertyValue -Object $_ -PropertyName 'DisplayName' -Default (Get-ObjectPropertyValue -Object $_ -PropertyName 'EntityId')) } } ) return @( $sorted | Select-Object -First $MaxCount | ForEach-Object { [pscustomobject]@{ EntityId = Get-ObjectPropertyValue -Object $_ -PropertyName 'EntityId' EntityType = Get-ObjectPropertyValue -Object $_ -PropertyName 'EntityType' DisplayName = Get-ObjectPropertyValue -Object $_ -PropertyName 'DisplayName' -Default (Get-ObjectPropertyValue -Object $_ -PropertyName 'EntityId') WorstSeverity = Get-ObjectPropertyValue -Object $_ -PropertyName 'WorstSeverity' NonCompliantCount = [int](Get-ObjectPropertyValue -Object $_ -PropertyName 'NonCompliantCount' -Default 0) MonthlyCost = [double](Get-ObjectPropertyValue -Object $_ -PropertyName 'MonthlyCost' -Default 0) Currency = Get-ObjectPropertyValue -Object $_ -PropertyName 'Currency' } } ) } function Get-PortfolioRollup { <# .SYNOPSIS Aggregates the current entity and finding stores into a portfolio view. .DESCRIPTION Produces subscription and management-group rollups for multi-subscription scans. The returned object is stable JSON-friendly shape suitable for portfolio.json and report rendering. #> [CmdletBinding()] param ( [object] $Store, [object[]] $Entities, [object[]] $Findings, [string] $ManagementGroupId ) if ($Store) { if ($null -eq $Entities -and $Store.PSObject.Methods['GetEntities']) { $Entities = @($Store.GetEntities()) } if ($null -eq $Findings -and $Store.PSObject.Methods['GetFindings']) { $Findings = @($Store.GetFindings()) } } if ($null -eq $Entities) { $Entities = @() } if ($null -eq $Findings) { $Findings = @() } $subscriptionIndex = @{} foreach ($item in @($Entities) + @($Findings)) { if (-not $item) { continue } $subscriptionId = Resolve-SubscriptionId -InputObject $item $subscriptionName = Get-ObjectPropertyValue -Object $item -PropertyName 'SubscriptionName' $path = @() $rawPath = Get-ObjectPropertyValue -Object $item -PropertyName 'ManagementGroupPath' if ($rawPath) { $path = @($rawPath | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) } if (-not $subscriptionId -and [string]::IsNullOrWhiteSpace([string]$subscriptionName)) { continue } $key = Get-SubscriptionBucketKey -InputObject $item if (-not $key) { continue } if (-not $subscriptionIndex.ContainsKey($key)) { $subscriptionIndex[$key] = [ordered]@{ SubscriptionId = $subscriptionId SubscriptionName = if ($subscriptionName) { $subscriptionName } else { $subscriptionId } ManagementGroupPath = $path } continue } if (-not $subscriptionIndex[$key].SubscriptionName -and $subscriptionName) { $subscriptionIndex[$key].SubscriptionName = $subscriptionName } if ((@($path).Count -gt 0) -and (@($subscriptionIndex[$key].ManagementGroupPath).Count -eq 0 -or @($path).Count -gt @($subscriptionIndex[$key].ManagementGroupPath).Count)) { $subscriptionIndex[$key].ManagementGroupPath = $path } } $findingsBySubscription = @{} foreach ($finding in $Findings) { if (-not $finding -or (Get-ObjectPropertyValue -Object $finding -PropertyName 'Category') -eq 'CrossSubscriptionCorrelation') { continue } $bucketKey = Get-SubscriptionBucketKey -InputObject $finding if (-not $bucketKey) { continue } if (-not $findingsBySubscription.ContainsKey($bucketKey)) { $findingsBySubscription[$bucketKey] = [System.Collections.Generic.List[object]]::new() } $findingsBySubscription[$bucketKey].Add($finding) | Out-Null } $entitiesBySubscription = @{} foreach ($entity in $Entities) { if (-not $entity) { continue } $bucketKey = Get-SubscriptionBucketKey -InputObject $entity if (-not $bucketKey) { continue } if (-not $entitiesBySubscription.ContainsKey($bucketKey)) { $entitiesBySubscription[$bucketKey] = [System.Collections.Generic.List[object]]::new() } $entitiesBySubscription[$bucketKey].Add($entity) | Out-Null } $subscriptionRows = [System.Collections.Generic.List[object]]::new() foreach ($bucketKey in $subscriptionIndex.Keys) { $entry = $subscriptionIndex[$bucketKey] $subId = $entry.SubscriptionId $subName = if ($entry.SubscriptionName) { $entry.SubscriptionName } elseif ($subId) { $subId } else { 'unknown-subscription' } $subFindings = if ($findingsBySubscription.ContainsKey($bucketKey)) { @($findingsBySubscription[$bucketKey]) } else { @() } $subEntities = if ($entitiesBySubscription.ContainsKey($bucketKey)) { @($entitiesBySubscription[$bucketKey]) } else { @() } $severityCounts = New-PortfolioSeverityCounts -Findings $subFindings $sourceCounts = @( $subFindings | Where-Object { $_ -and $_.PSObject.Properties['Compliant'] -and -not $_.Compliant } | Group-Object -Property Source | Sort-Object ` @{ Expression = 'Count'; Descending = $true }, ` @{ Expression = 'Name' } | ForEach-Object { [pscustomobject]@{ Source = $_.Name Count = $_.Count } } ) $monthlyCost = [double]0 $currency = '' foreach ($entity in $subEntities) { $cost = Get-ObjectPropertyValue -Object $entity -PropertyName 'MonthlyCost' if ($null -ne $cost -and "$cost" -ne '') { $monthlyCost += [double]$cost } if (-not $currency) { $currency = [string](Get-ObjectPropertyValue -Object $entity -PropertyName 'Currency' -Default '') } } $worstSeverity = 'Info' foreach ($level in @('Critical', 'High', 'Medium', 'Low', 'Info')) { if ([int]$severityCounts.$level -gt 0) { $worstSeverity = $level break } } $subscriptionRows.Add([pscustomobject]@{ SubscriptionId = $subId SubscriptionName = $subName ManagementGroupPath = @($entry.ManagementGroupPath) FindingCount = @($subFindings).Count NonCompliantCount = @($subFindings | Where-Object { $_ -and $_.PSObject.Properties['Compliant'] -and -not $_.Compliant }).Count SeverityCounts = $severityCounts SourceCounts = $sourceCounts TopEntities = @(Get-TopPortfolioEntities -Entities $subEntities) MonthlyCost = [math]::Round($monthlyCost, 2) Currency = $currency WorstSeverity = $worstSeverity }) | Out-Null } $subscriptionRows = @( $subscriptionRows | Sort-Object ` @{ Expression = { Get-SeverityRank $_.WorstSeverity }; Descending = $true }, ` @{ Expression = { [int]$_.NonCompliantCount }; Descending = $true }, ` @{ Expression = { [string]$_.SubscriptionName } } ) $managementGroupBuckets = @{} foreach ($row in $subscriptionRows) { $path = @($row.ManagementGroupPath) if ($path.Count -eq 0) { continue } $key = $path -join ' > ' if (-not $managementGroupBuckets.ContainsKey($key)) { $managementGroupBuckets[$key] = [System.Collections.Generic.List[object]]::new() } $managementGroupBuckets[$key].Add($row) | Out-Null } $managementGroupRows = [System.Collections.Generic.List[object]]::new() foreach ($bucketKey in $managementGroupBuckets.Keys) { $rows = @($managementGroupBuckets[$bucketKey]) $mgPath = @() if ($rows.Count -gt 0) { $mgPath = @($rows[0].ManagementGroupPath) } elseif ($ManagementGroupId) { $mgPath = @($ManagementGroupId) } $criticalCount = 0 $highCount = 0 $mediumCount = 0 $lowCount = 0 $infoCount = 0 foreach ($row in $rows) { $criticalCount += [int]$row.SeverityCounts.Critical $highCount += [int]$row.SeverityCounts.High $mediumCount += [int]$row.SeverityCounts.Medium $lowCount += [int]$row.SeverityCounts.Low $infoCount += [int]$row.SeverityCounts.Info } $severityCounts = [pscustomobject]@{ Critical = $criticalCount High = $highCount Medium = $mediumCount Low = $lowCount Info = $infoCount } $managementGroupRows.Add([pscustomobject]@{ ManagementGroupName = if ($mgPath.Count -gt 0) { $mgPath[-1] } elseif ($ManagementGroupId) { $ManagementGroupId } else { 'portfolio' } ManagementGroupPath = $mgPath SubscriptionCount = $rows.Count NonCompliantCount = (@($rows | Measure-Object -Property NonCompliantCount -Sum).Sum ?? 0) SeverityCounts = $severityCounts MonthlyCost = [math]::Round((@($rows | Measure-Object -Property MonthlyCost -Sum).Sum ?? 0), 2) Currency = [string]($rows | ForEach-Object { $_.Currency } | Where-Object { $_ } | Select-Object -First 1) }) | Out-Null } $correlations = @( $Findings | Where-Object { $_ -and $_.Source -eq 'identity-correlator' -and $_.Category -eq 'CrossSubscriptionCorrelation' } | Sort-Object ` @{ Expression = { Get-SeverityRank $_.Severity }; Descending = $true }, ` @{ Expression = { [string]$_.Title } } ) return [pscustomobject]@{ SchemaVersion = '1.0' GeneratedAt = (Get-Date).ToUniversalTime().ToString('o') Summary = [pscustomobject]@{ ManagementGroupId = $ManagementGroupId SubscriptionCount = @($subscriptionRows).Count ManagementGroupCount = @($managementGroupRows).Count CorrelationCount = @($correlations).Count TotalFindings = @($Findings).Count TotalNonCompliant = @($Findings | Where-Object { $_ -and $_.PSObject.Properties['Compliant'] -and -not $_.Compliant }).Count } Subscriptions = $subscriptionRows ManagementGroups = @($managementGroupRows) Correlations = $correlations } } |