modules/shared/Schema.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Schema v2 factories and validation helpers. .DESCRIPTION Provides constructors for finding rows and entity stubs, plus validation functions that return boolean pass/fail results with detailed errors. #> [CmdletBinding()] param () Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:SchemaVersion = '2.2' # FindingRow v2.2 (additive, back-compat with v2.1): # * Adds 13 optional fields populated by the per-tool ETL closures # (#300-#313): Frameworks (now first-class with hashtable shape), # Pillar, Impact, Effort, DeepLinkUrl, RemediationSnippets, # EvidenceUris, BaselineTags, ScoreDelta, MitreTactics, # MitreTechniques, EntityRefs, ToolVersion. All zero-value defaults. # No enum tightening, no rename, no behaviour change for existing # callers. # FindingRow v2.1 (additive, back-compat with v2.0): # * Adds optional RuleId field (default '') for stable rule identification # used by the HTML collapsible-tree level-3 grouping, framework mapping # (RuleIdPrefix), and downstream rule-quality dashboards. # * Adds AdoProject and KarpenterProvisioner to the EntityType enum. # Schema bump: entities.json moves from a bare array (v3.0) to an object # { SchemaVersion: '3.1', Entities: [...], Edges: [...] } when edges are present. # Readers must support both shapes (back-compat). $script:EntitiesFileSchemaVersion = '3.1' $script:SeverityLevels = @('Critical', 'High', 'Medium', 'Low', 'Info') # Edge.Relation enum. Add new values as discovery surfaces grow. # Phase 0 (#435) adds 16 additive relations consumed by Tracks A/B/C graph # renderers (attack-path, resilience, policy). All existing relations are # preserved; readers must accept any string in this set. $script:EdgeRelations = @( 'GuestOf', # User -> Tenant (B2B home tenant) 'MemberOf', # User|ServicePrincipal -> Group/role 'HasRoleOn', # ServicePrincipal|User -> AzureResource (RBAC) 'OwnsAppRegistration', # User|ServicePrincipal -> Application 'ConsentedTo', # User|ServicePrincipal -> Application (delegated/admin consent) # --- Track A: attack-path / pipeline trust (Phase 0 #435) --- 'TriggeredBy', # Workflow|Pipeline -> Event/Trigger 'AuthenticatesAs', # Workflow|Pipeline -> ServicePrincipal|ManagedIdentity 'DeploysTo', # Workflow|Pipeline -> AzureResource|Subscription 'UsesSecret', # Workflow|Pipeline -> Secret/VariableGroup 'HasFederatedCredential', # Application|ServicePrincipal -> Repository|Workflow 'Declares', # IaCFile -> AzureResource # --- Track B: resilience / topology (Phase 0 #435) --- 'DependsOn', # AzureResource -> AzureResource (runtime dependency) 'RegionPinned', # AzureResource -> Region 'ZonePinned', # AzureResource -> AvailabilityZone 'BackedUpBy', # AzureResource -> BackupVault/Policy 'FailsOverTo', # AzureResource -> AzureResource (paired DR target) 'ReplicatedTo', # AzureResource -> AzureResource (geo-replica) # --- Track C: policy / governance (Phase 0 #435) --- 'PolicyAssignedTo', # PolicyAssignment -> Subscription|MG|ResourceGroup|Resource 'PolicyEnforces', # PolicyAssignment -> PolicyDefinition 'ExemptedFrom', # AzureResource -> PolicyAssignment 'InheritsFrom', # ManagementGroup|Subscription -> ManagementGroup # --- Graph mapping family (R1, docs/design/graph-mapping-integration.md) --- 'AppliesTo', # ConditionalAccessPolicy -> User|Group|Application|NamedLocation 'Excludes', # ConditionalAccessPolicy -> User|Group|Application|NamedLocation 'EligibleFor', # User|ServicePrincipal -> AzureResource (PIM eligible role) 'ActiveAs', # User|ServicePrincipal -> AzureResource (PIM active assignment) 'EffectivePermission', # ServicePrincipal|User -> Application (transitive grant) 'OnPremShadow' # User -> OnPremUser (hybrid identity) ) $script:EntityTypes = @( 'AzureResource', 'ServicePrincipal', 'ManagedIdentity', 'Application', 'Repository', 'IaCFile', 'BuildDefinition', 'ReleaseDefinition', 'Pipeline', 'VariableGroup', 'Environment', 'ServiceConnection', 'User', 'Subscription', 'ManagementGroup', 'Workflow', 'Tenant', 'AdoProject', 'KarpenterProvisioner', 'ExternalAsset', # --- Graph mapping family (R1, docs/design/graph-mapping-integration.md) --- 'ConditionalAccessPolicy', 'NamedLocation', 'OnPremUser' ) $script:Platforms = @('Azure', 'Entra', 'GitHub', 'ADO', 'AzureDevOps', 'IaC', 'External', 'OnPrem') $script:ConfidenceLevels = @('Confirmed', 'Likely', 'Unconfirmed', 'Unknown') $script:ValidationFailures = [System.Collections.Generic.List[PSCustomObject]]::new() # Validation failure tracking $script:ValidationFailures = [System.Collections.Generic.List[PSCustomObject]]::new() function Get-SchemaValidationFailures { <# .SYNOPSIS Retrieve logged validation failures. .DESCRIPTION Returns a list of validation failures logged during FindingRow construction. #> return ,$script:ValidationFailures.ToArray() } function Reset-SchemaValidationFailures { <# .SYNOPSIS Clear validation failure log. .DESCRIPTION Clears the internal list of validation failures. #> $script:ValidationFailures.Clear() } function Get-PlatformForEntityType { param ( [Parameter(Mandatory)] [ValidateSet( 'AzureResource', 'ServicePrincipal', 'ManagedIdentity', 'Application', 'Repository', 'IaCFile', 'BuildDefinition', 'ReleaseDefinition', 'Pipeline', 'VariableGroup', 'Environment', 'ServiceConnection', 'User', 'Subscription', 'ManagementGroup', 'Workflow', 'Tenant', 'AdoProject', 'KarpenterProvisioner', 'ExternalAsset', 'ConditionalAccessPolicy', 'NamedLocation', 'OnPremUser' )] [string] $EntityType ) switch ($EntityType) { 'AzureResource' { 'Azure' } 'ManagedIdentity' { 'Azure' } 'Subscription' { 'Azure' } 'ManagementGroup' { 'Azure' } 'KarpenterProvisioner' { 'Azure' } 'ServicePrincipal' { 'Entra' } 'Application' { 'Entra' } 'User' { 'Entra' } 'Tenant' { 'Entra' } 'Repository' { 'GitHub' } 'IaCFile' { 'IaC' } 'BuildDefinition' { 'AzureDevOps' } 'ReleaseDefinition' { 'AzureDevOps' } 'Workflow' { 'GitHub' } 'Pipeline' { 'ADO' } 'VariableGroup' { 'ADO' } 'Environment' { 'ADO' } 'ServiceConnection' { 'ADO' } 'AdoProject' { 'ADO' } 'ExternalAsset' { 'External' } 'ConditionalAccessPolicy' { 'Entra' } 'NamedLocation' { 'Entra' } 'OnPremUser' { 'OnPrem' } default { throw "Unknown EntityType '$EntityType'." } } } function Get-SchemaValidationFailures { <# .SYNOPSIS Retrieve the list of schema validation failures recorded during the current session. .DESCRIPTION Returns an array of PSCustomObjects with Source, Error, Timestamp. #> [CmdletBinding()] param () return $script:ValidationFailures.ToArray() } function Reset-SchemaValidationFailures { <# .SYNOPSIS Clear all recorded schema validation failures. #> [CmdletBinding()] param () $script:ValidationFailures.Clear() } function New-FindingRow { <# .SYNOPSIS Create a schema v2 finding row with required and optional fields. .DESCRIPTION Initializes all known fields. Required fields are validated and canonical platform metadata is inferred when omitted. .PARAMETER Id Finding identifier (GUID or tool-provided ID). .PARAMETER Source Tool source key (e.g., azqr, psrule). .PARAMETER EntityId Canonical entity identifier. .PARAMETER EntityType Entity type enum. .PARAMETER Title Human-readable title of the finding. .PARAMETER RuleId Stable identifier of the rule that produced the finding (e.g. 'Azure.KeyVault.SoftDelete', 'finops-appserviceplan-idle-cpu', 'MT.1010'). Optional; defaults to ''. When supplied, the HTML report collapsible tree uses it as the level-3 grouping key (#275, #229) and framework mapping (RuleIdPrefix) keys off it. Added in v2.1. .PARAMETER Compliant Boolean compliance status. .PARAMETER ProvenanceRunId Run identifier for the tool execution. .NOTES Schema v2.1 (additive, back-compat with v2.0): * RuleId field added (optional, default ''). * EntityType enum extended with AdoProject + KarpenterProvisioner. Existing callers continue to work unchanged. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] [string] $Id, [Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] [string] $Source, [Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] [string] $EntityId, [Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] [string] $EntityType, [Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] [string] $Title, [string] $RuleId = '', [Parameter(Mandatory)] [object] $Compliant, [Parameter(Mandatory)] [AllowNull()] [AllowEmptyString()] [string] $ProvenanceRunId, [string] $Category, [string] $Severity, [string] $Detail, [string] $Remediation, [string] $ResourceId, [string] $LearnMoreUrl, [string] $Platform, [string] $SubscriptionId, [string] $SubscriptionName, [string] $ResourceGroup, [string[]] $ManagementGroupPath, [object[]] $Frameworks, [string[]] $Controls, [string] $Confidence, [int] $EvidenceCount, [string[]] $MissingDimensions, [string] $ProvenanceSource, [string] $ProvenanceRawRecordRef, [datetime] $ProvenanceTimestamp, # --- Schema 2.2 additive fields (#299) --- # All optional with zero-value defaults; populated by per-tool ETL # closures (#300-#313). No behaviour change for callers that omit them. [string] $Pillar = '', [string] $Impact = '', [string] $Effort = '', [string] $DeepLinkUrl = '', [hashtable[]] $RemediationSnippets = @(), [string[]] $EvidenceUris = @(), [string[]] $BaselineTags = @(), [Nullable[double]] $ScoreDelta = $null, [string[]] $MitreTactics = @(), [string[]] $MitreTechniques = @(), [string[]] $EntityRefs = @(), [string] $ToolVersion = '', # Phase 0 (#435) schema-hook contract per Round 3 reconciliation. # Accepts arbitrary additional optional fields (additive, dual-read, # ignored if unknown). Track-specific FindingRow extensions (#432b) # populate this until they graduate to first-class params. Keys that # collide with already-declared row fields are silently dropped to # protect schema invariants. [hashtable] $AdditionalFields = @{}, [string] $SchemaVersion = $script:SchemaVersion ) $preValidationErrors = [System.Collections.Generic.List[string]]::new() foreach ($required in @( @{ Name = 'Id'; Value = $Id }, @{ Name = 'Source'; Value = $Source }, @{ Name = 'EntityId'; Value = $EntityId }, @{ Name = 'EntityType'; Value = $EntityType }, @{ Name = 'Title'; Value = $Title }, @{ Name = 'ProvenanceRunId'; Value = $ProvenanceRunId } )) { if ([string]::IsNullOrWhiteSpace([string]$required.Value)) { $preValidationErrors.Add("Required parameter '$($required.Name)' is missing or empty.") } } if ($EntityType -and $EntityType -notin $script:EntityTypes) { $preValidationErrors.Add("EntityType '$EntityType' is not valid. Valid types: $($script:EntityTypes -join ', ').") } if ($Severity -and $Severity -notin $script:SeverityLevels) { $preValidationErrors.Add("Severity '$Severity' is not valid. Valid levels: $($script:SeverityLevels -join ', ').") } if ($Platform -and $Platform -notin $script:Platforms) { $preValidationErrors.Add("Platform '$Platform' is not valid. Valid platforms: $($script:Platforms -join ', ').") } if ($Confidence -and $Confidence -notin $script:ConfidenceLevels) { $preValidationErrors.Add("Confidence '$Confidence' is not valid. Valid confidence levels: $($script:ConfidenceLevels -join ', ').") } if ($null -eq $Compliant) { $preValidationErrors.Add("Required parameter 'Compliant' is missing.") } elseif ($Compliant -isnot [bool]) { $preValidationErrors.Add("Compliant must be a boolean value, got '$($Compliant.GetType().Name)'.") } if ($preValidationErrors.Count -gt 0) { $sourceForLog = if ([string]::IsNullOrWhiteSpace([string]$Source)) { 'unknown' } else { $Source } $sanitizedError = if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) { Remove-Credentials ($preValidationErrors -join '; ') } else { $preValidationErrors -join '; ' } $script:ValidationFailures.Add([PSCustomObject]@{ Source = $sourceForLog Error = $sanitizedError Timestamp = Get-Date }) Write-Warning "FindingRow validation failed [$sourceForLog]: $sanitizedError" return $null } $resolvedPlatform = if ($Platform) { $Platform } else { Get-PlatformForEntityType -EntityType $EntityType } $provenance = [PSCustomObject]@{ RunId = $ProvenanceRunId Source = if ($ProvenanceSource) { $ProvenanceSource } else { $Source } RawRecordRef = $ProvenanceRawRecordRef Timestamp = if ($ProvenanceTimestamp) { $ProvenanceTimestamp.ToUniversalTime().ToString('o') } else { (Get-Date).ToUniversalTime().ToString('o') } } $row = [PSCustomObject]@{ Id = $Id Source = $Source Category = $Category Title = $Title RuleId = $RuleId Severity = $Severity Compliant = [bool]$Compliant Detail = $Detail Remediation = $Remediation ResourceId = $ResourceId LearnMoreUrl = $LearnMoreUrl EntityId = $EntityId EntityType = $EntityType Platform = $resolvedPlatform Provenance = $provenance SubscriptionId = $SubscriptionId SubscriptionName = $SubscriptionName ResourceGroup = $ResourceGroup ManagementGroupPath = $ManagementGroupPath Frameworks = $Frameworks Controls = $Controls Confidence = $Confidence EvidenceCount = $EvidenceCount MissingDimensions = $MissingDimensions Pillar = $Pillar Impact = $Impact Effort = $Effort DeepLinkUrl = $DeepLinkUrl RemediationSnippets = $RemediationSnippets EvidenceUris = $EvidenceUris BaselineTags = $BaselineTags ScoreDelta = $ScoreDelta MitreTactics = $MitreTactics MitreTechniques = $MitreTechniques EntityRefs = $EntityRefs ToolVersion = $ToolVersion SchemaVersion = $SchemaVersion } # Phase 0 (#435) schema-hook: merge AdditionalFields into the row, # skipping any key that would overwrite a first-class field. This is the # only sanctioned path for Track-specific extensions until #432b promotes # them. Unknown-key dropouts are silent (intentional dual-read posture). if ($AdditionalFields -and $AdditionalFields.Count -gt 0) { $existingNames = @($row.PSObject.Properties.Name) foreach ($key in @($AdditionalFields.Keys)) { if ([string]::IsNullOrWhiteSpace([string]$key)) { continue } if ($existingNames -contains [string]$key) { continue } $row | Add-Member -MemberType NoteProperty -Name ([string]$key) -Value $AdditionalFields[$key] -Force } } # Validate the row before returning it $validationErrors = @() $isValid = Test-FindingRow -Finding $row -ErrorDetails ([ref]$validationErrors) if (-not $isValid) { # Sanitize error message $sanitizedError = if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) { Remove-Credentials ($validationErrors -join '; ') } else { $validationErrors -join '; ' } # Log the failure $script:ValidationFailures.Add([PSCustomObject]@{ Source = $Source Error = $sanitizedError Timestamp = Get-Date }) # Write warning to stderr Write-Warning "FindingRow validation failed [$Source]: $sanitizedError" # Return null to signal failure (caller should skip this row) return $null } return $row } function New-EntityStub { <# .SYNOPSIS Create an entity stub with canonical identity and empty observations. .DESCRIPTION CanonicalId is mapped to the EntityId field. Observations are initialized as an empty array by default. .PARAMETER CanonicalId Canonical entity identifier. .PARAMETER EntityType Entity type enum. .PARAMETER Platform Entity platform. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $CanonicalId, [Parameter(Mandatory)] [ValidateSet( 'AzureResource', 'ServicePrincipal', 'ManagedIdentity', 'Application', 'Repository', 'IaCFile', 'BuildDefinition', 'ReleaseDefinition', 'Pipeline', 'ServiceConnection', 'User', 'Subscription', 'ManagementGroup', 'Workflow', 'Tenant', 'AdoProject', 'KarpenterProvisioner', 'ConditionalAccessPolicy', 'NamedLocation', 'OnPremUser', 'ExternalAsset' )] [string] $EntityType, [ValidateSet('Azure', 'Entra', 'GitHub', 'ADO', 'AzureDevOps', 'IaC', 'OnPrem', 'External')] [string] $Platform, [string] $DisplayName, [string] $SubscriptionId, [string] $SubscriptionName, [string] $ResourceGroup, [string[]] $ManagementGroupPath, [object[]] $ExternalIds, [object[]] $Frameworks, [string[]] $Controls, [object[]] $Policies, [object[]] $Correlations, [double] $MonthlyCost, [string] $Currency, [string] $CostTrend, [ValidateSet('Confirmed', 'Likely', 'Unconfirmed', 'Unknown')] [string] $Confidence, [string[]] $MissingDimensions, [object[]] $Observations ) $resolvedPlatform = if ($Platform) { $Platform } else { Get-PlatformForEntityType -EntityType $EntityType } $initialObservations = if ($Observations) { @($Observations) } else { @() } [PSCustomObject]@{ EntityId = $CanonicalId EntityType = $EntityType Platform = $resolvedPlatform DisplayName = $DisplayName SubscriptionId = $SubscriptionId SubscriptionName = $SubscriptionName ResourceGroup = $ResourceGroup ManagementGroupPath = $ManagementGroupPath ExternalIds = $ExternalIds Observations = $initialObservations WorstSeverity = $null CompliantCount = 0 NonCompliantCount = 0 Sources = @() MonthlyCost = $MonthlyCost Currency = $Currency CostTrend = $CostTrend Frameworks = $Frameworks Controls = $Controls Policies = $Policies Correlations = $Correlations Confidence = $Confidence MissingDimensions = $MissingDimensions } } function Test-FindingRow { <# .SYNOPSIS Validate a finding row against schema v2/v3 requirements. .DESCRIPTION By default returns $true/$false with errors via -ErrorDetails. Use -Strict to throw a FindingRowSchemaException instead. .PARAMETER Finding Finding row to validate. .PARAMETER ErrorDetails Output array of validation errors (populated when -Strict is not used). .PARAMETER Strict When set, throws a FindingRowSchemaException with all validation errors instead of returning false. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNull()] [pscustomobject] $Finding, [ref] $ErrorDetails, [switch] $Strict ) $errors = [System.Collections.Generic.List[string]]::new() # Required fields foreach ($required in @('Id', 'Source', 'EntityId', 'EntityType', 'Title', 'SchemaVersion')) { if (-not $Finding.PSObject.Properties[$required]) { $errors.Add("Required field '$required' is missing.") } elseif ([string]::IsNullOrWhiteSpace([string]$Finding.$required)) { $errors.Add("Required field '$required' is empty.") } } # Compliant must be present and boolean if (-not $Finding.PSObject.Properties['Compliant']) { $errors.Add("Required field 'Compliant' is missing.") } elseif ($null -eq $Finding.Compliant) { $errors.Add("Compliant must be a boolean value, got 'null'.") } elseif ($Finding.Compliant -isnot [bool]) { $errors.Add("Compliant must be a boolean value, got '$($Finding.Compliant.GetType().Name)'.") } # EntityType validation if ($Finding.PSObject.Properties['EntityType'] -and $Finding.EntityType) { if ($Finding.EntityType -notin $script:EntityTypes) { $errors.Add("EntityType '$($Finding.EntityType)' is not valid. Valid types: $($script:EntityTypes -join ', ').") } } # Platform validation if ($Finding.PSObject.Properties['Platform'] -and $Finding.Platform) { if ($Finding.Platform -notin $script:Platforms) { $errors.Add("Platform '$($Finding.Platform)' is not valid. Valid platforms: $($script:Platforms -join ', ').") } } # Severity validation if ($Finding.PSObject.Properties['Severity'] -and $Finding.Severity) { if ($Finding.Severity -notin $script:SeverityLevels) { $errors.Add("Severity '$($Finding.Severity)' is not valid. Valid levels: $($script:SeverityLevels -join ', ').") } } # Provenance.RunId validation if (-not $Finding.PSObject.Properties['Provenance']) { $errors.Add("Provenance is missing.") } elseif (-not $Finding.Provenance) { $errors.Add("Provenance is null.") } elseif (-not $Finding.Provenance.PSObject.Properties['RunId']) { $errors.Add("Provenance.RunId is missing.") } elseif ([string]::IsNullOrWhiteSpace($Finding.Provenance.RunId)) { $errors.Add("Provenance.RunId is empty.") } # EntityId canonicalization check (when possible) if ($Finding.PSObject.Properties['EntityId'] -and $Finding.EntityId -and $Finding.PSObject.Properties['EntityType'] -and $Finding.EntityType -and (-not ($Finding.PSObject.Properties['Platform'] -and $Finding.Platform -eq 'AzureDevOps')) -and (Get-Command ConvertTo-CanonicalEntityId -ErrorAction SilentlyContinue)) { try { $result = ConvertTo-CanonicalEntityId -RawId $Finding.EntityId -EntityType $Finding.EntityType $canonicalId = $result.CanonicalId # Use case-sensitive comparison (-cne) if ($canonicalId -cne $Finding.EntityId) { $errors.Add("EntityId canonicalization check failed: expected '$canonicalId', got '$($Finding.EntityId)'.") } } catch { # Canonicalization failed, record the error $errors.Add("EntityId canonicalization check failed: $_") } } # Return or throw $isValid = $errors.Count -eq 0 if (-not $isValid -and $Strict) { $aggregatedError = "FindingRow schema validation failed with $($errors.Count) error(s):`n" + ($errors -join "`n") throw [System.Exception]::new($aggregatedError) } if ($ErrorDetails) { $ErrorDetails.Value = $errors.ToArray() } return $isValid } function Get-EdgeRelations { <# .SYNOPSIS Returns the allowed Edge.Relation values. #> [CmdletBinding()] param () return ,$script:EdgeRelations } function New-Edge { <# .SYNOPSIS Construct a v3.1 Edge object representing a relationship between two entities. .DESCRIPTION Edges are first-class records persisted alongside entities. The EdgeId is deterministic ("edge:{source}:{relation}:{target}" lower-cased) so that repeated discovery rounds dedup naturally. Returns $null with a warning when validation fails (mirrors New-FindingRow contract). .PARAMETER Source Canonical entity id of the source vertex. .PARAMETER Target Canonical entity id of the target vertex. .PARAMETER Relation One of the Edge.Relation enum values (see Get-EdgeRelations). .PARAMETER Properties Hashtable / PSCustomObject of relation-specific metadata (e.g. role name, scope, consent type). Optional. .PARAMETER Confidence Confirmed | Likely | Unconfirmed | Unknown. .PARAMETER Platform Platform that owns this edge fact (Azure | Entra | GitHub | ADO). .PARAMETER DiscoveredBy Wrapper / tool that produced the edge. .PARAMETER DiscoveredAt ISO-8601 timestamp; defaults to UtcNow. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string] $Source, [Parameter(Mandatory)] [AllowEmptyString()] [string] $Target, [Parameter(Mandatory)] [AllowEmptyString()] [string] $Relation, [object] $Properties, [string] $Confidence = 'Unknown', [string] $Platform, [string] $DiscoveredBy, [datetime] $DiscoveredAt ) $errors = [System.Collections.Generic.List[string]]::new() if ([string]::IsNullOrWhiteSpace($Source)) { $errors.Add("Required parameter 'Source' is missing or empty.") } if ([string]::IsNullOrWhiteSpace($Target)) { $errors.Add("Required parameter 'Target' is missing or empty.") } if ([string]::IsNullOrWhiteSpace($Relation)) { $errors.Add("Required parameter 'Relation' is missing or empty.") } elseif ($Relation -notin $script:EdgeRelations) { $errors.Add("Relation '$Relation' is not valid. Valid relations: $($script:EdgeRelations -join ', ').") } if ($Confidence -and $Confidence -notin $script:ConfidenceLevels) { $errors.Add("Confidence '$Confidence' is not valid. Valid confidence levels: $($script:ConfidenceLevels -join ', ').") } if ($Platform -and $Platform -notin $script:Platforms) { $errors.Add("Platform '$Platform' is not valid. Valid platforms: $($script:Platforms -join ', ').") } if ($errors.Count -gt 0) { $sourceForLog = if ([string]::IsNullOrWhiteSpace($DiscoveredBy)) { 'unknown' } else { $DiscoveredBy } $sanitizedError = if (Get-Command Remove-Credentials -ErrorAction SilentlyContinue) { Remove-Credentials ($errors -join '; ') } else { $errors -join '; ' } $script:ValidationFailures.Add([PSCustomObject]@{ Source = $sourceForLog Error = "Edge validation failed: $sanitizedError" Timestamp = Get-Date }) Write-Warning "Edge validation failed [$sourceForLog]: $sanitizedError" return $null } $srcLower = $Source.Trim().ToLowerInvariant() $tgtLower = $Target.Trim().ToLowerInvariant() $edgeId = "edge:$srcLower|$Relation|$tgtLower" $propBag = if ($null -eq $Properties) { [PSCustomObject]@{} } elseif ($Properties -is [System.Collections.IDictionary]) { [PSCustomObject]$Properties } else { $Properties } $stamp = if ($PSBoundParameters.ContainsKey('DiscoveredAt') -and $DiscoveredAt) { $DiscoveredAt.ToUniversalTime().ToString('o') } else { (Get-Date).ToUniversalTime().ToString('o') } [PSCustomObject]@{ EdgeId = $edgeId Source = $srcLower Target = $tgtLower Relation = $Relation Properties = $propBag Confidence = $Confidence Platform = $Platform DiscoveredBy = $DiscoveredBy DiscoveredAt = $stamp SchemaVersion = $script:EntitiesFileSchemaVersion } } function Test-Edge { <# .SYNOPSIS Validate an edge object against the v3.1 contract. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNull()] [pscustomobject] $Edge, [ref] $ErrorDetails ) $errors = [System.Collections.Generic.List[string]]::new() foreach ($required in @('EdgeId', 'Source', 'Target', 'Relation')) { if (-not $Edge.PSObject.Properties[$required] -or [string]::IsNullOrWhiteSpace([string]$Edge.$required)) { $errors.Add("Required field '$required' is missing or empty.") } } if ($Edge.PSObject.Properties['Relation'] -and $Edge.Relation -and $Edge.Relation -notin $script:EdgeRelations) { $errors.Add("Relation '$($Edge.Relation)' is not in allowed set: $($script:EdgeRelations -join ', ').") } if ($Edge.PSObject.Properties['Confidence'] -and $Edge.Confidence -and $Edge.Confidence -notin $script:ConfidenceLevels) { $errors.Add("Confidence '$($Edge.Confidence)' is not in allowed set: $($script:ConfidenceLevels -join ', ').") } if ($Edge.PSObject.Properties['Platform'] -and $Edge.Platform -and $Edge.Platform -notin $script:Platforms) { $errors.Add("Platform '$($Edge.Platform)' is not in allowed set: $($script:Platforms -join ', ').") } if ($ErrorDetails) { $ErrorDetails.Value = $errors.ToArray() } return $errors.Count -eq 0 } function Test-EntityRecord { <# .SYNOPSIS Validate an entity record. .DESCRIPTION Returns $true when valid. When invalid, returns $false and provides error details via -ErrorDetails. .PARAMETER Entity Entity record to validate. .PARAMETER ErrorDetails Output array of validation errors. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNull()] [pscustomobject] $Entity, [ref] $ErrorDetails ) $errors = [System.Collections.Generic.List[string]]::new() foreach ($required in @('EntityId', 'EntityType', 'Platform')) { if (-not $Entity.PSObject.Properties[$required] -or [string]::IsNullOrWhiteSpace([string]$Entity.$required)) { $errors.Add("Required field '$required' is missing or empty.") } } if ($Entity.EntityType -and $Entity.EntityType -notin $script:EntityTypes) { $errors.Add("EntityType '$($Entity.EntityType)' is not in the allowed set: $($script:EntityTypes -join ', ').") } if ($Entity.Platform -and $Entity.Platform -notin $script:Platforms) { $errors.Add("Platform '$($Entity.Platform)' is not in the allowed set: $($script:Platforms -join ', ').") } if ($Entity.Observations -and $Entity.Observations -isnot [System.Collections.IEnumerable]) { $errors.Add("Observations must be an array.") } $canonicalizer = Get-Command -Name ConvertTo-CanonicalEntityId -ErrorAction SilentlyContinue if ($canonicalizer -and $Entity.EntityId -and $Entity.EntityType -and $Entity.Platform -ne 'AzureDevOps') { try { $canonical = ConvertTo-CanonicalEntityId -RawId $Entity.EntityId -EntityType $Entity.EntityType if ($canonical.CanonicalId -cne $Entity.EntityId) { $errors.Add("EntityId is not canonicalized. Expected '$($canonical.CanonicalId)'.") } } catch { $errors.Add("EntityId canonicalization failed: $_") } } if ($ErrorDetails) { $ErrorDetails.Value = $errors.ToArray() } return $errors.Count -eq 0 } |