modules/normalizers/Normalize-FinOpsSignals.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Normalizer for FinOps idle/unused resource signals. .DESCRIPTION Converts v1 finops wrapper output to schema v2 FindingRows. #> [CmdletBinding()] param () . "$PSScriptRoot\..\shared\Schema.ps1" . "$PSScriptRoot\..\shared\Canonicalize.ps1" function ConvertTo-FinOpsSeverity { [CmdletBinding()] param ( [AllowNull()] [string] $RawSeverity, [AllowNull()] [double] $EstimatedMonthlyCost ) if (-not [string]::IsNullOrWhiteSpace($RawSeverity)) { switch -Regex ($RawSeverity.ToLowerInvariant()) { '^critical$' { return 'Critical' } '^high$' { return 'High' } '^(medium|moderate)$' { return 'Medium' } '^low$' { return 'Low' } '^info(nformational)?$' { } default { } } } if ($EstimatedMonthlyCost -gt 500) { return 'Medium' } if ($EstimatedMonthlyCost -ge 50) { return 'Low' } return 'Info' } function Resolve-FinOpsImpact { param ([double] $EstimatedMonthlyCost) if ($EstimatedMonthlyCost -gt 500) { return 'High' } if ($EstimatedMonthlyCost -ge 100) { return 'Medium' } return 'Low' } function Resolve-FinOpsEffort { param ( [string] $DetectionCategory, [string] $RuleId, [string] $Title, [string] $ResourceType ) $text = ("$DetectionCategory $RuleId $Title $ResourceType").ToLowerInvariant() if ($text -match 'stopped virtual machines|stopped vm|deallocated|idlevm|virtualmachines') { return 'Low' } if ($text -match 'unattached|orphaned|orphaneddisk|managed disk|snapshot') { return 'Low' } if ($text -match 'public ip|empty resource groups') { return 'Low' } if ($text -match 'oversizedsku|appserviceplanidlecpu|app service plan|serverfarms|rightsize|sku') { return 'Medium' } if ($text -match 'architecturalredesign|network controls|load balancer|networksecuritygroups|nsg') { return 'High' } return 'Medium' } function Resolve-CostManagementDeepLink { param ( [string] $SubscriptionId, [string] $ResourceId ) if ([string]::IsNullOrWhiteSpace($SubscriptionId)) { if ($ResourceId -match '/subscriptions/([^/]+)') { $SubscriptionId = [string]$Matches[1] } } if ([string]::IsNullOrWhiteSpace($SubscriptionId)) { return '' } $scope = "/subscriptions/$SubscriptionId" $scopeEncoded = [uri]::EscapeDataString($scope) $base = "https://portal.azure.com/#view/Microsoft_Azure_CostManagement/Menu/~/costanalysis/openingScope/$scopeEncoded" if ([string]::IsNullOrWhiteSpace($ResourceId)) { return $base } return "${base}?resourceId=$([uri]::EscapeDataString($ResourceId))" } function Convert-ToRemediationSnippets { param ([string] $Recommendation) if ([string]::IsNullOrWhiteSpace($Recommendation)) { return @() } return @( @{ language = 'bash'; content = "# $Recommendation" }, @{ language = 'powershell'; content = "# $Recommendation" } ) } function Normalize-FinOpsSignals { [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject] $ToolResult ) if ($ToolResult.Status -notin @('Success', 'PartialSuccess') -or -not $ToolResult.Findings) { return @() } $runId = [guid]::NewGuid().ToString() $normalized = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($f in $ToolResult.Findings) { $rawId = if ($f.PSObject.Properties['ResourceId'] -and $f.ResourceId) { [string]$f.ResourceId } else { '' } if ([string]::IsNullOrWhiteSpace($rawId)) { continue } $canonicalId = $rawId.ToLowerInvariant() try { $canonicalId = (ConvertTo-CanonicalEntityId -RawId $rawId -EntityType 'AzureResource').CanonicalId } catch { $canonicalId = $rawId.ToLowerInvariant() } $subId = '' $rg = '' if ($rawId -match '/subscriptions/([^/]+)') { $subId = $Matches[1] } if ($rawId -match '/resourceGroups/([^/]+)') { $rg = $Matches[1] } $monthlyCost = 0.0 if ($f.PSObject.Properties['EstimatedMonthlyCost'] -and $null -ne $f.EstimatedMonthlyCost) { try { $monthlyCost = [double]$f.EstimatedMonthlyCost } catch { $monthlyCost = 0.0 } } $currency = if ($f.PSObject.Properties['Currency'] -and $f.Currency) { [string]$f.Currency } else { '' } $severity = ConvertTo-FinOpsSeverity -RawSeverity ([string]$f.Severity) -EstimatedMonthlyCost $monthlyCost $detail = if ($f.PSObject.Properties['Detail'] -and $f.Detail) { [string]$f.Detail } else { '' } if ($monthlyCost -gt 0) { $detail = "$detail Estimated monthly waste: $monthlyCost $currency." } $detectionCategory = if ($f.PSObject.Properties['DetectionCategory'] -and $f.DetectionCategory) { [string]$f.DetectionCategory } else { '' } $ruleId = if ($f.PSObject.Properties['RuleId'] -and $f.RuleId) { [string]$f.RuleId } else { '' } $remediation = 'Review whether this resource can be deleted, downscaled, or rightsized.' if ($detectionCategory -eq 'AppServicePlanIdleCpu') { $remediation = 'Review App Service Plan utilization and rightsize SKU/instance count or consolidate workloads when average CPU stays below 5% for 30 days.' } elseif ($detectionCategory -eq 'AppServicePlanIdleCpuMetricsDegraded') { $remediation = 'Grant Azure Monitor metrics read access (for example Monitoring Reader) and re-run finops so App Service Plan CPU idle signals can be evaluated.' } $findingId = if ($f.PSObject.Properties['Id'] -and $f.Id) { [string]$f.Id } else { [guid]::NewGuid().ToString() } $title = if ($f.PSObject.Properties['Title'] -and $f.Title) { [string]$f.Title } else { 'FinOps idle resource signal' } $category = if ($f.PSObject.Properties['Category'] -and $f.Category) { [string]$f.Category } else { 'Cost' } $resourceType = if ($f.PSObject.Properties['ResourceType'] -and $f.ResourceType) { [string]$f.ResourceType } else { '' } $impact = Resolve-FinOpsImpact -EstimatedMonthlyCost $monthlyCost $effort = Resolve-FinOpsEffort -DetectionCategory $detectionCategory -RuleId $ruleId -Title $title -ResourceType $resourceType $deepLinkUrl = Resolve-CostManagementDeepLink -SubscriptionId $subId -ResourceId $rawId $queryId = if ($f.PSObject.Properties['QueryId'] -and $f.QueryId) { [string]$f.QueryId } else { '' } $queryEvidenceUrl = if ([string]::IsNullOrWhiteSpace($queryId)) { 'https://github.com/martinopedal/alz-graph-queries' } else { "https://github.com/martinopedal/alz-graph-queries/search?q=$([uri]::EscapeDataString($queryId))" } $evidenceUris = @($queryEvidenceUrl) if (-not [string]::IsNullOrWhiteSpace($deepLinkUrl)) { $evidenceUris += $deepLinkUrl } $recommendation = if ($f.PSObject.Properties['Recommendation'] -and $f.Recommendation) { [string]$f.Recommendation } else { $remediation } $remediationSnippets = @(Convert-ToRemediationSnippets -Recommendation $recommendation) [Nullable[double]]$scoreDelta = $null if ($monthlyCost -gt 0) { $scoreDelta = [double]$monthlyCost } $entityRefs = @() if (-not [string]::IsNullOrWhiteSpace($subId)) { $entityRefs += $subId } if (-not [string]::IsNullOrWhiteSpace($rawId)) { $entityRefs += $rawId } $toolVersion = if ($f.PSObject.Properties['ToolVersion'] -and $f.ToolVersion) { [string]$f.ToolVersion } elseif ($ToolResult.PSObject.Properties['ToolVersion'] -and $ToolResult.ToolVersion) { [string]$ToolResult.ToolVersion } else { '' } $row = New-FindingRow -Id $findingId ` -Source 'finops' -EntityId $canonicalId -EntityType 'AzureResource' ` -Title $title -Compliant $false -ProvenanceRunId $runId ` -Platform 'Azure' -Category $category -Severity $severity ` -Detail $detail -Remediation $remediation ` -LearnMoreUrl ([string]$f.LearnMoreUrl) -ResourceId $rawId ` -SubscriptionId $subId -ResourceGroup $rg ` -RuleId $ruleId -Pillar 'Cost Optimization' -Impact $impact -Effort $effort ` -DeepLinkUrl $deepLinkUrl -RemediationSnippets $remediationSnippets ` -EvidenceUris $evidenceUris -ScoreDelta $scoreDelta -EntityRefs $entityRefs ` -ToolVersion $toolVersion if ($null -eq $row) { continue } $row | Add-Member -NotePropertyName MonthlyCost -NotePropertyValue $monthlyCost -Force $row | Add-Member -NotePropertyName Currency -NotePropertyValue $currency -Force if (-not [string]::IsNullOrWhiteSpace($detectionCategory)) { $row | Add-Member -NotePropertyName DetectionCategory -NotePropertyValue $detectionCategory -Force } $normalized.Add($row) } return @($normalized) } |