modules/normalizers/Normalize-Maester.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Normalizer for Maester (Entra ID security) findings. .DESCRIPTION Converts raw Maester wrapper output to v3 FindingRow objects. Platform=Entra, EntityType=Tenant. All Maester findings map to a single synthetic tenant entity. #> [CmdletBinding()] param () . "$PSScriptRoot\..\shared\Schema.ps1" . "$PSScriptRoot\..\shared\Canonicalize.ps1" function ConvertTo-MaesterStringArray { param([object] $Value) $values = [System.Collections.Generic.List[string]]::new() foreach ($item in @($Value)) { if ($null -eq $item) { continue } if ($item -is [string]) { if (-not [string]::IsNullOrWhiteSpace($item)) { $values.Add($item.Trim()) | Out-Null } continue } if ($item -is [System.Collections.IEnumerable] -and $item -isnot [string]) { foreach ($nested in @(ConvertTo-MaesterStringArray -Value $item)) { if (-not [string]::IsNullOrWhiteSpace($nested)) { $values.Add($nested) | Out-Null } } continue } $candidate = [string]$item if (-not [string]::IsNullOrWhiteSpace($candidate)) { $values.Add($candidate.Trim()) | Out-Null } } return @($values | Select-Object -Unique) } function ConvertTo-MaesterEntityRefs { param( [string] $TenantId, [string[]] $RawRefs ) $refs = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace($TenantId)) { try { $refs.Add((ConvertTo-CanonicalEntityId -RawId $TenantId -EntityType 'Tenant').CanonicalId) | Out-Null } catch { $refs.Add($TenantId.ToLowerInvariant()) | Out-Null } } foreach ($raw in ConvertTo-MaesterStringArray -Value $RawRefs) { if ([string]::IsNullOrWhiteSpace($raw)) { continue } if ($raw -match '^(?i)tenant:') { $refs.Add($raw.ToLowerInvariant()) | Out-Null continue } if ($raw -match '^[0-9a-fA-F-]{36}$' -or $raw -match '^(?i)(appid|objectid):[0-9a-fA-F-]{36}$') { try { $refs.Add((ConvertTo-CanonicalEntityId -RawId $raw -EntityType 'ServicePrincipal').CanonicalId) | Out-Null } catch { $refs.Add($raw.ToLowerInvariant()) | Out-Null } continue } $refs.Add($raw) | Out-Null } return @($refs | Select-Object -Unique) } function Normalize-Maester { [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject] $ToolResult, [System.Collections.Generic.List[psobject]] $EdgeCollector ) if ($ToolResult.Status -ne 'Success' -or -not $ToolResult.Findings) { return @() } $runId = [guid]::NewGuid().ToString() $normalized = [System.Collections.Generic.List[PSCustomObject]]::new() # Resolve tenant entity ID: prefer real TenantId from tool output; fall back to synthetic. $tenantRaw = if ($ToolResult.PSObject.Properties['TenantId'] -and $ToolResult.TenantId) { [string]$ToolResult.TenantId } else { 'entra-tenant-configuration' } $tenantEntityId = (ConvertTo-CanonicalEntityId -RawId $tenantRaw -EntityType 'Tenant').CanonicalId function Add-MaesterTrackAEdges { param([object] $Candidate) if ($null -eq $EdgeCollector) { return } if ($null -eq $Candidate -or -not $Candidate.PSObject.Properties['AttackPathEdges']) { return } $allowedRelations = @('TriggeredBy', 'AuthenticatesAs', 'DeploysTo', 'UsesSecret', 'HasFederatedCredential', 'Declares') foreach ($edgeHint in @($Candidate.AttackPathEdges)) { if ($null -eq $edgeHint) { continue } $source = if ($edgeHint.PSObject.Properties['Source']) { [string]$edgeHint.Source } else { '' } $target = if ($edgeHint.PSObject.Properties['Target']) { [string]$edgeHint.Target } else { '' } $relation = if ($edgeHint.PSObject.Properties['Relation']) { [string]$edgeHint.Relation } else { '' } if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($target)) { continue } if ($relation -notin $allowedRelations) { continue } $edge = New-Edge -Source $source -Target $target -Relation $relation -Confidence 'Likely' -Platform 'Entra' -DiscoveredBy 'maester' if ($null -ne $edge) { $EdgeCollector.Add($edge) | Out-Null } } } foreach ($finding in $ToolResult.Findings) { Add-MaesterTrackAEdges -Candidate $finding $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) { [string]$finding.Id } else { [guid]::NewGuid().ToString() } $title = if ($finding.PSObject.Properties['Title'] -and $finding.Title) { $finding.Title } else { 'Unknown' } $category = if ($finding.PSObject.Properties['Category'] -and $finding.Category) { $finding.Category } else { 'Identity' } $rawSev = if ($finding.PSObject.Properties['Severity'] -and $finding.Severity) { $finding.Severity } else { 'Medium' } # Word-boundary match so tags like "criticality.info" don't become Critical/High. $severity = switch -Regex ($rawSev.ToString().ToLowerInvariant()) { '\bcritical\b' { 'Critical'; break } '\bhigh\b' { 'High'; break } '\b(medium|moderate)\b' { 'Medium'; break } '\blow\b' { 'Low'; break } '\b(info|informational)\b' { 'Info'; break } default { 'Medium' } } $compliant = if ($finding.PSObject.Properties['Compliant']) { [bool]$finding.Compliant } else { $true } $detail = if ($finding.PSObject.Properties['Detail'] -and $finding.Detail) { $finding.Detail } else { '' } $remediation = if ($finding.PSObject.Properties['Remediation'] -and $finding.Remediation) { $finding.Remediation } else { '' } $learnMore = if ($finding.PSObject.Properties['LearnMoreUrl'] -and $finding.LearnMoreUrl) { $finding.LearnMoreUrl } else { '' } $ruleId = if ($finding.PSObject.Properties['TestId'] -and $finding.TestId) { [string]$finding.TestId } else { '' } $frameworks = if ($finding.PSObject.Properties['Frameworks'] -and $finding.Frameworks) { @($finding.Frameworks) } else { @() } $pillar = if ($finding.PSObject.Properties['Pillar'] -and $finding.Pillar) { [string]$finding.Pillar } else { 'Security' } $deepLinkUrl = if ($finding.PSObject.Properties['DeepLinkUrl'] -and $finding.DeepLinkUrl) { [string]$finding.DeepLinkUrl } else { if ($ruleId) { "https://maester.dev/docs/tests/$ruleId" } else { '' } } $baselineTags = if ($finding.PSObject.Properties['BaselineTags'] -and $finding.BaselineTags) { @(ConvertTo-MaesterStringArray -Value $finding.BaselineTags) } else { @() } $mitreTactics = if ($finding.PSObject.Properties['MitreTactics'] -and $finding.MitreTactics) { @(ConvertTo-MaesterStringArray -Value $finding.MitreTactics) } else { @() } $mitreTechniques = if ($finding.PSObject.Properties['MitreTechniques'] -and $finding.MitreTechniques) { @(ConvertTo-MaesterStringArray -Value $finding.MitreTechniques) } else { @() } $evidenceUris = if ($finding.PSObject.Properties['EvidenceUris'] -and $finding.EvidenceUris) { @(ConvertTo-MaesterStringArray -Value $finding.EvidenceUris | Where-Object { $_ -match '^(?i)https://' }) } else { @() } if (@($evidenceUris).Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($learnMore)) { $evidenceUris = @($learnMore) } $remediationSnippets = @() if ($finding.PSObject.Properties['RemediationSnippets'] -and $finding.RemediationSnippets) { $remediationSnippets = @($finding.RemediationSnippets | ForEach-Object { if ($null -eq $_) { return } if ($_ -is [hashtable]) { return $_ } if ($_ -is [System.Collections.IDictionary]) { $h = @{} foreach ($k in $_.Keys) { $h[[string]$k] = $_[$k] } return $h } $language = [string]$_.language $code = [string]$_.code if ([string]::IsNullOrWhiteSpace($code)) { return } return @{ language = if ([string]::IsNullOrWhiteSpace($language)) { 'text' } else { $language } code = $code } }) } $entityRefs = ConvertTo-MaesterEntityRefs -TenantId $tenantRaw -RawRefs $(if ($finding.PSObject.Properties['EntityRefs']) { [string[]]$finding.EntityRefs } else { @() }) $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } elseif ($ToolResult.PSObject.Properties['ToolVersion'] -and $ToolResult.ToolVersion) { [string]$ToolResult.ToolVersion } else { '' } $row = New-FindingRow -Id $findingId ` -Source 'maester' -EntityId $tenantEntityId -EntityType 'Tenant' ` -Title $title -RuleId $ruleId -Compliant ([bool]$compliant) -ProvenanceRunId $runId ` -Platform 'Entra' -Category $category -Severity $severity ` -Detail $detail -Remediation $remediation ` -LearnMoreUrl $learnMore -ResourceId '' ` -Frameworks @($frameworks) -Pillar $pillar -DeepLinkUrl $deepLinkUrl ` -RemediationSnippets @($remediationSnippets) -EvidenceUris @($evidenceUris) ` -BaselineTags @($baselineTags) -MitreTactics @($mitreTactics) -MitreTechniques @($mitreTechniques) ` -EntityRefs @($entityRefs) -ToolVersion $toolVersion # Skip null rows (validation failed) if ($null -ne $row) { $normalized.Add($row) } } return @($normalized) } |