modules/shared/AprlCatalog.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS APRL recommendation-catalog helpers for enriching WARA findings. .DESCRIPTION The WARA collector emits APRL reliability findings whose title, severity, detail and learn-more link are not always present on the raw record; those findings would otherwise ship with Title = 'Unknown'. Each finding still carries the APRL recommendation GUID (as RecommendationId, and as the first segment of Id when split on '::'). Joining that GUID to the APRL recommendation catalog recovers the missing metadata. Merge-WaraAprlMetadata is a pure, offline function: it takes a findings list and a catalog hashtable and backfills only findings that are still 'Unknown'. Get-WaraAprlCatalog is a best-effort, HTTPS-only loader that degrades to $null on any failure, so the wrapper has no hard network dependency. #> Set-StrictMode -Version Latest function Get-AprlPropertyValue { param( [object] $Object, [Parameter(Mandatory)][string[]] $Names ) if ($null -eq $Object) { return $null } foreach ($name in $Names) { $prop = $Object.PSObject.Properties[$name] if ($prop -and $null -ne $prop.Value) { if ($prop.Value -is [string]) { if (-not [string]::IsNullOrWhiteSpace($prop.Value)) { return $prop.Value } } else { return $prop.Value } } } return $null } function Set-AprlFindingProperty { param( [Parameter(Mandatory)][object] $Finding, [Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][AllowEmptyString()][object] $Value ) if ($Finding.PSObject.Properties[$Name]) { $Finding.$Name = $Value } else { Add-Member -InputObject $Finding -MemberType NoteProperty -Name $Name -Value $Value -Force } } function ConvertTo-AprlSeverity { param([string] $Value) if ([string]::IsNullOrWhiteSpace($Value)) { return '' } switch -Regex ($Value.Trim().ToLowerInvariant()) { '^crit' { return 'Critical' } '^high$' { return 'High' } '^med' { return 'Medium' } '^low$' { return 'Low' } '^info' { return 'Info' } default { return 'Medium' } } } function ConvertTo-AprlCategoryName { <# .SYNOPSIS Converts an APRL recommendationControl value into a display category. .DESCRIPTION APRL publishes the control as a PascalCase token ('HighAvailability', 'MonitoringAndAlerting'). Reports render this value directly as a grouping label via Get-Domain, so it is expanded to readable words here rather than in the renderer. The nine values published today are mapped explicitly so the wording is stable and reviewable. Anything else falls back to a PascalCase split, because APRL adds controls over time and an unknown control should still group sensibly instead of being dropped. #> param([string] $Value) if ([string]::IsNullOrWhiteSpace($Value)) { return '' } $trimmed = $Value.Trim() switch ($trimmed.ToLowerInvariant()) { 'highavailability' { return 'High Availability' } 'disasterrecovery' { return 'Disaster Recovery' } 'monitoringandalerting' { return 'Monitoring and Alerting' } 'scalability' { return 'Scalability' } 'otherbestpractices' { return 'Other Best Practices' } 'businesscontinuity' { return 'Business Continuity' } 'serviceupgradeandretirement' { return 'Service Upgrade and Retirement' } 'personalized' { return 'Personalized' } 'security' { return 'Security' } } # Fallback: split PascalCase, then lower-case conjunctions for readability. $split = [regex]::Replace($trimmed, '(?<=[a-z0-9])(?=[A-Z])', ' ') return ($split -replace '\bAnd\b', 'and') } function Test-AprlSafeUrl { <# .SYNOPSIS Returns $true only for absolute HTTPS URLs. .DESCRIPTION Catalog-sourced links are rendered into the HTML report as clickable <a href='...'> targets. The report HTML-encodes the value, which stops attribute breakout but does NOT stop a hostile scheme: 'javascript:...' contains no encodable characters and stays live on click. Enforcing the repo's HTTPS-only invariant at ingest is the reliable place to stop it. #> param([string] $Url) if ([string]::IsNullOrWhiteSpace($Url)) { return $false } $uri = $null if (-not [System.Uri]::TryCreate($Url.Trim(), [System.UriKind]::Absolute, [ref]$uri)) { return $false } return ($uri.Scheme -eq 'https') } function Get-AprlDefaultCachePath { <# .SYNOPSIS User-scoped cache path for the APRL catalog. .DESCRIPTION Deliberately NOT the shared temp directory. Other temp files in this repo are write-then-read within a single run and carry a random GUID in the name; this cache is the opposite - a stable, predictable path that a later run reads back and trusts. On Linux/macOS the shared temp dir is world-writable, so a predictable name there lets any local user pre-create the file and choose the titles, details and links that land in the report. LocalApplicationData is user-scoped and removes that. #> $root = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($root)) { $root = [System.IO.Path]::GetTempPath() } return (Join-Path (Join-Path $root 'azure-analyzer') 'wara-aprl-catalog.json') } function Get-AprlLearnMoreUrl { param([object] $Record) if ($null -eq $Record) { return '' } $link = Get-AprlPropertyValue -Object $Record -Names @('learnMoreLink', 'learnMoreUrl', 'learnMore') if ($null -eq $link) { return '' } $candidate = '' if ($link -is [string]) { $candidate = $link } else { $first = @($link) | Select-Object -First 1 if ($null -eq $first) { return '' } if ($first -is [string]) { $candidate = $first } else { $candidate = [string](Get-AprlPropertyValue -Object $first -Names @('url', 'href', 'link')) } } if (-not (Test-AprlSafeUrl $candidate)) { return '' } return $candidate.Trim() } function ConvertTo-WaraAprlCatalog { <# .SYNOPSIS Builds a GUID-keyed hashtable from an array of APRL recommendation records. #> param( [Parameter(Mandatory)][AllowNull()][object[]] $Records ) $catalog = @{} if ($null -eq $Records) { return $catalog } foreach ($record in $Records) { if ($null -eq $record) { continue } $guid = [string](Get-AprlPropertyValue -Object $record -Names @('aprlGuid', 'recommendationId', 'guid', 'id')) if ([string]::IsNullOrWhiteSpace($guid)) { continue } $catalog[$guid.Trim().ToLowerInvariant()] = $record } return $catalog } function Get-WaraAprlCatalog { <# .SYNOPSIS Loads the APRL recommendation catalog (cache first, then optional fetch). .DESCRIPTION Best-effort and offline-safe. Returns a GUID-keyed hashtable, or $null if no catalog could be loaded. Only HTTPS URLs are fetched. The cache is treated as valid only while it is younger than MaxAgeHours. APRL publishes new recommendation GUIDs continuously, so an unbounded cache would permanently fail to resolve every GUID added after the first successful fetch - the enrichment would silently rot instead of failing. A stale cache is still kept as a fallback: if the refresh fetch fails we prefer stale metadata over no metadata. #> [CmdletBinding()] param( [string] $Path, [string] $Url = 'https://azure.github.io/WARA-Build/objects/recommendations.json', [int] $MaxAgeHours = 168, [switch] $Refresh ) $records = $null $staleRecords = $null if ($Path -and (Test-Path $Path) -and -not $Refresh) { try { $cached = Get-Content -Path $Path -Raw | ConvertFrom-Json $ageHours = ([DateTime]::UtcNow - (Get-Item -LiteralPath $Path).LastWriteTimeUtc).TotalHours if ($MaxAgeHours -le 0 -or $ageHours -le $MaxAgeHours) { $records = $cached } else { $staleRecords = $cached Write-Verbose ("APRL catalog cache is {0:N0}h old (max {1}h); refreshing." -f $ageHours, $MaxAgeHours) } } catch { $records = $null } } if ($null -eq $records -and -not [string]::IsNullOrWhiteSpace($Url)) { if ($Url -notmatch '^https://') { return $null } try { $fetch = { Invoke-RestMethod -Uri $Url -Method Get -TimeoutSec 30 -ErrorAction Stop } if (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue) { $records = Invoke-WithRetry -ScriptBlock $fetch -MaxAttempts 3 -InitialDelaySeconds 2 } else { $records = & $fetch } if ($Path) { try { $parent = Split-Path -Parent $Path if ($parent -and -not (Test-Path $parent)) { $null = New-Item -ItemType Directory -Path $parent -Force } $records | ConvertTo-Json -Depth 12 | Set-Content -Path $Path -Encoding UTF8 } catch { Write-Verbose "APRL catalog cache write skipped: $([string]$_)" } } } catch { Write-Verbose "APRL catalog fetch failed: $([string]$_)" $records = $staleRecords } } if ($null -eq $records) { return $null } return ConvertTo-WaraAprlCatalog -Records @($records) } function Merge-WaraAprlMetadata { <# .SYNOPSIS Backfills Title/Severity/Detail/LearnMoreUrl and Category on WARA findings. .DESCRIPTION Pure and offline. Title/Severity/Detail/LearnMoreUrl are only written when the finding's Title is empty or 'Unknown', so existing good metadata is never clobbered. Category is deliberately handled differently: it is applied to every finding with a catalog match, not just the broken ones. Category drives report grouping (New-HtmlReport Get-Domain falls back to Category when Pillar is empty, which is the normal case for WARA because APRL control names do not map onto WAF pillar names). Enriching only the 'Unknown' findings would leave most findings in the flat default bucket and the rollup would stay useless, which is the whole point of the change. The existing Category is preserved unless it is empty or the synthetic 'Reliability' default that Invoke-WARA stamps when the collector supplies no category of its own. 'Reliability' is never a real APRL control value, so treating it as "unset" is unambiguous. The APRL GUID is read from RecommendationId, falling back to the first '::' segment of Id. Returns the same findings collection for convenience. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowNull()][object] $Findings, [Parameter(Mandatory)][hashtable] $Catalog ) if ($null -eq $Findings -or $null -eq $Catalog -or $Catalog.Count -eq 0) { return $Findings } foreach ($finding in @($Findings)) { if ($null -eq $finding) { continue } # Resolved before the Title gate because category enrichment below # applies to every matched finding, not only the ones missing a title. $guid = [string](Get-AprlPropertyValue -Object $finding -Names @('RecommendationId')) if ([string]::IsNullOrWhiteSpace($guid)) { $idValue = [string](Get-AprlPropertyValue -Object $finding -Names @('Id')) if (-not [string]::IsNullOrWhiteSpace($idValue)) { $guid = ($idValue -split '::')[0] } } if ([string]::IsNullOrWhiteSpace($guid)) { continue } $key = $guid.Trim().ToLowerInvariant() if (-not $Catalog.ContainsKey($key)) { continue } $record = $Catalog[$key] $catalogControl = [string](Get-AprlPropertyValue -Object $record -Names @('recommendationControl', 'category', 'recommendationCategory')) if (-not [string]::IsNullOrWhiteSpace($catalogControl)) { $categoryName = ConvertTo-AprlCategoryName $catalogControl if (-not [string]::IsNullOrWhiteSpace($categoryName)) { $existingCategory = [string](Get-AprlPropertyValue -Object $finding -Names @('Category')) if ([string]::IsNullOrWhiteSpace($existingCategory) -or $existingCategory -eq 'Reliability') { Set-AprlFindingProperty -Finding $finding -Name 'Category' -Value $categoryName } # Raw token kept alongside the display name so downstream # rollups can group on a stable key rather than prose. Set-AprlFindingProperty -Finding $finding -Name 'AprlControl' -Value $catalogControl } } $title = [string](Get-AprlPropertyValue -Object $finding -Names @('Title')) $needsEnrichment = [string]::IsNullOrWhiteSpace($title) -or $title -eq 'Unknown' if (-not $needsEnrichment) { continue } $catalogTitle = [string](Get-AprlPropertyValue -Object $record -Names @('description', 'title', 'recommendationTitle')) if (-not [string]::IsNullOrWhiteSpace($catalogTitle)) { Set-AprlFindingProperty -Finding $finding -Name 'Title' -Value $catalogTitle } $catalogSeverity = ConvertTo-AprlSeverity ([string](Get-AprlPropertyValue -Object $record -Names @('recommendationImpact', 'impact', 'severity'))) if (-not [string]::IsNullOrWhiteSpace($catalogSeverity)) { Set-AprlFindingProperty -Finding $finding -Name 'Severity' -Value $catalogSeverity } $existingDetail = [string](Get-AprlPropertyValue -Object $finding -Names @('Detail')) $catalogDetail = [string](Get-AprlPropertyValue -Object $record -Names @('longDescription', 'description')) if ([string]::IsNullOrWhiteSpace($existingDetail) -and -not [string]::IsNullOrWhiteSpace($catalogDetail)) { Set-AprlFindingProperty -Finding $finding -Name 'Detail' -Value $catalogDetail } $existingUrl = [string](Get-AprlPropertyValue -Object $finding -Names @('LearnMoreUrl')) if ([string]::IsNullOrWhiteSpace($existingUrl)) { $catalogUrl = Get-AprlLearnMoreUrl -Record $record if (-not [string]::IsNullOrWhiteSpace($catalogUrl)) { Set-AprlFindingProperty -Finding $finding -Name 'LearnMoreUrl' -Value $catalogUrl Set-AprlFindingProperty -Finding $finding -Name 'DeepLinkUrl' -Value $catalogUrl } } } return $Findings } |