Modules/Collectors/70-WafAssessmentCollector.ps1

function Resolve-RangerManifestPath {
    <#
    .SYNOPSIS
        Resolves a dot-notation path against the audit manifest hashtable.
    .DESCRIPTION
        Walks the manifest using dot-separated path segments and returns the value
        at that location. Returns $null if any segment is missing or the path is invalid.
        Supports IDictionary (hashtable / ordered hashtable) and PSObject properties.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    $segments = $Path -split '\.'
    $current  = $Manifest
    foreach ($seg in $segments) {
        if ($null -eq $current) { return $null }
        if ($current -is [System.Collections.IDictionary]) {
            if (-not $current.Contains($seg)) { return $null }
            $current = $current[$seg]
        } elseif ($null -ne $current.PSObject) {
            $prop = $current.PSObject.Properties[$seg]
            if ($null -eq $prop) { return $null }
            $current = $prop.Value
        } else {
            return $null
        }
    }
    return $current
}

function Invoke-RangerWafCalculation {
    <#
    .SYNOPSIS
        v1.6.0 (#214): compute a named aggregate metric from the manifest.
    .DESCRIPTION
        Supports aggregates: min, max, avg, sum, count, pct (percentage of
        truthy values). `source` is a manifestPath that resolves to an array
        (or array-like); `field` is the property to aggregate per element.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [Parameter(Mandatory = $true)]
        $Definition
    )

    $source = $Definition.source
    $field  = $Definition.field
    $agg    = [string]($Definition.aggregate)

    if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($agg)) {
        return $null
    }

    $raw = Resolve-RangerManifestPath -Manifest $Manifest -Path $source
    $collection = @($raw | Where-Object { $_ -ne $null })
    if ($collection.Count -eq 0) { return $null }

    # When field is specified, project each element; otherwise treat the item itself as the value.
    $values = if ([string]::IsNullOrWhiteSpace($field)) {
        $collection
    } else {
        @($collection | ForEach-Object {
            if ($_ -is [System.Collections.IDictionary]) { $_[$field] }
            elseif ($_.PSObject -and $_.PSObject.Properties[$field]) { $_.$field }
            else { $null }
        })
    }

    switch ($agg) {
        'min' {
            $numeric = @($values | Where-Object { $_ -ne $null } | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ })
            if ($numeric.Count -eq 0) { return $null }
            return [double]($numeric | Measure-Object -Minimum).Minimum
        }
        'max' {
            $numeric = @($values | Where-Object { $_ -ne $null } | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ })
            if ($numeric.Count -eq 0) { return $null }
            return [double]($numeric | Measure-Object -Maximum).Maximum
        }
        'avg' {
            $numeric = @($values | Where-Object { $_ -ne $null } | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ })
            if ($numeric.Count -eq 0) { return $null }
            return [double]($numeric | Measure-Object -Average).Average
        }
        'sum' {
            $numeric = @($values | Where-Object { $_ -ne $null } | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ })
            if ($numeric.Count -eq 0) { return 0 }
            return [double]($numeric | Measure-Object -Sum).Sum
        }
        'count' {
            return [int]$collection.Count
        }
        'pct' {
            if ($collection.Count -eq 0) { return 0 }
            $truthy = @($values | Where-Object { $_ -eq $true -or $_ -eq 1 -or ($_ -is [string] -and $_ -in @('true','True','yes','ok','healthy')) }).Count
            return [math]::Round(($truthy / $collection.Count) * 100, 1)
        }
        default { return $null }
    }
}

function Invoke-RangerWafRuleEvaluation {
    <#
    .SYNOPSIS
        Evaluates WAF rules from waf-rules.json against the audit manifest.
    .DESCRIPTION
        Loads the rule definitions from config/waf-rules.json in the module root,
        evaluates each rule against the current manifest, and returns a structured
        result object per pillar suitable for the WAF Scorecard report section.

        Rules do not require re-collection - this function can be called against any
        saved manifest by regenerating reports with Export-AzureLocalRangerReport.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    # Locate waf-rules.json relative to the installed module
    $moduleBase  = (Get-Module AzureLocalRanger -ErrorAction SilentlyContinue).ModuleBase
    $rulesPath   = if ($moduleBase) { Join-Path $moduleBase 'config/waf-rules.json' } else { $null }
    $rulesData   = $null

    if ($rulesPath -and (Test-Path -Path $rulesPath -PathType Leaf)) {
        try {
            $rulesData = Get-Content -Path $rulesPath -Raw | ConvertFrom-Json
        } catch {
            Write-Warning "WAF rule evaluation: failed to load waf-rules.json - $($_.Exception.Message)"
        }
    }

    if ($null -eq $rulesData -or @($rulesData.rules).Count -eq 0) {
        return [ordered]@{
            pillarScores   = @()
            ruleResults    = @()
            advisorFindings = @()
            summary        = [ordered]@{ totalRules = 0; passingRules = 0; overallScore = 0; status = 'no-rules' }
        }
    }

    # v1.6.0 (#214): pre-compute named calculations once so rules can reference
    # aggregate metrics (min/avg/max/sum/pct) by name.
    $calculations = @{}
    if ($rulesData.calculations) {
        foreach ($calcName in @($rulesData.calculations.PSObject.Properties.Name)) {
            $def = $rulesData.calculations.$calcName
            try {
                $calculations[$calcName] = Invoke-RangerWafCalculation -Manifest $Manifest -Definition $def
            } catch {
                Write-Warning "WAF calculation '$calcName' failed: $($_.Exception.Message)"
                $calculations[$calcName] = $null
            }
        }
    }

    $ruleResults = New-Object System.Collections.ArrayList

    foreach ($rule in $rulesData.rules) {
        # v1.6.0 (#214): graduated threshold scoring path — rule has a named
        # `calculation` reference and a `thresholds` array (descending min).
        if ($rule.calculation -and $rule.thresholds) {
            $value = $calculations[[string]$rule.calculation]
            $maxPts = if ($null -ne $rule.maxPoints) { [int]$rule.maxPoints } elseif ($null -ne $rule.points) { [int]$rule.points } else { 1 }

            if ($null -eq $value) {
                Write-Warning "WAF rule '$($rule.id)' references undefined or null calculation '$($rule.calculation)' — skipping."
                [void]$ruleResults.Add([ordered]@{
                    id             = $rule.id
                    pillar         = $rule.pillar
                    title          = $rule.title
                    description    = $rule.description
                    severity       = $rule.severity
                    recommendation = $rule.recommendation
                    calculation    = [string]$rule.calculation
                    resolvedValue  = $null
                    awardedPoints  = 0
                    maxPoints      = $maxPts
                    pass           = $false
                    band           = 'skipped'
                    message        = "Calculation '$($rule.calculation)' not available."
                })
                continue
            }

            # Sort thresholds descending by `min`, take first matching band.
            $numeric  = [double]($value -as [double])
            $ordered  = @($rule.thresholds | Sort-Object { [double]$_.min } -Descending)
            $band     = $ordered | Where-Object { $numeric -ge [double]$_.min } | Select-Object -First 1
            if (-not $band) { $band = $ordered | Select-Object -Last 1 }
            $awarded  = if ($band) { [int]$band.points } else { 0 }
            $label    = if ($band -and $band.label) { [string]$band.label } else { 'Unknown' }
            $pass     = $awarded -ge $maxPts
            $msgKey   = if ($pass) { 'passMessage' } elseif ($awarded -gt 0) { 'warningMessage' } else { 'failMessage' }
            $template = [string]$rule.$msgKey
            $formatted = if ($numeric -eq [math]::Floor($numeric)) { [string][int]$numeric } else { '{0:N1}' -f $numeric }
            $message  = if ($template) { $template -replace '\{value\}', $formatted } else { "$label ($formatted)" }

            # v2.0.0 (#225): weight applies. Graduated bands already compute awarded/maxPts
            # fractional credit; weight multiplies both sides so the pillar roll-up mixes
            # weighted rules correctly.
            $weight           = if ($null -ne $rule.weight) { [double]$rule.weight } else { 1.0 }
            $weightedAwarded  = [double]$awarded * $weight
            $weightedMaxPts   = [double]$maxPts * $weight

            [void]$ruleResults.Add([ordered]@{
                id               = $rule.id
                pillar           = $rule.pillar
                title            = $rule.title
                description      = $rule.description
                severity         = $rule.severity
                recommendation   = $rule.recommendation
                calculation      = [string]$rule.calculation
                resolvedValue    = $numeric
                awardedPoints    = $awarded
                maxPoints        = $maxPts
                weight           = $weight
                weightedAwarded  = $weightedAwarded
                weightedMaxPoints = $weightedMaxPts
                pass             = $pass
                band             = $label
                message          = $message
            })
            continue
        }

        $rawValue = Resolve-RangerManifestPath -Manifest $Manifest -Path $rule.manifestPath
        $pass     = switch ($rule.check) {
            'equals'           {
                $null -ne $rawValue -and [string]$rawValue -eq [string]$rule.expected
            }
            'notEquals'        {
                [string]$rawValue -ne [string]$rule.expected
            }
            'greaterThan'      {
                $null -ne $rawValue -and $rawValue -isnot [array] -and
                    [double]($rawValue -as [double]) -gt [double]$rule.threshold
            }
            'lessThan'         {
                $null -ne $rawValue -and $rawValue -isnot [array] -and
                    [double]($rawValue -as [double]) -lt [double]$rule.threshold
            }
            'greaterThanOrEqual' {
                $null -ne $rawValue -and [double]($rawValue -as [double]) -ge [double]$rule.threshold
            }
            'lessThanOrEqual'  {
                $null -ne $rawValue -and [double]($rawValue -as [double]) -le [double]$rule.threshold
            }
            'notNull'          {
                $null -ne $rawValue -and [string]$rawValue -ne '' -and
                    [string]$rawValue -ne '(not recorded)' -and [string]$rawValue -ne 'null'
            }
            'boolTrue'         { $rawValue -eq $true }
            'boolFalse'        { $rawValue -eq $false }
            'countGreaterThan' { @($rawValue).Count -gt [int]$rule.threshold }
            'countEquals'      { @($rawValue).Count -eq [int]$rule.expected }
            default            { $false }
        }

        # Existing pass/fail rules award full points on pass, 0 on fail (#214).
        # v2.0.0 (#225): warnings now award 0.5 × weight (graduated credit for
        # informational/warning severity rules that don't have explicit graduated bands).
        $maxPts  = if ($null -ne $rule.maxPoints) { [int]$rule.maxPoints } elseif ($null -ne $rule.points) { [int]$rule.points } else { 1 }
        $warnSev = [string]$rule.severity -in @('warning', 'informational')
        $awarded = if ($pass) { $maxPts } elseif ($warnSev -and $null -ne $rawValue) { [double]($maxPts * 0.5) } else { 0 }

        $weight           = if ($null -ne $rule.weight) { [double]$rule.weight } else { 1.0 }
        $weightedAwarded  = [double]$awarded * $weight
        $weightedMaxPts   = [double]$maxPts   * $weight

        [void]$ruleResults.Add([ordered]@{
            id                = $rule.id
            pillar            = $rule.pillar
            title             = $rule.title
            description       = $rule.description
            severity          = $rule.severity
            recommendation    = $rule.recommendation
            manifestPath      = $rule.manifestPath
            resolvedValue     = $rawValue
            awardedPoints     = $awarded
            maxPoints         = $maxPts
            weight            = $weight
            weightedAwarded   = $weightedAwarded
            weightedMaxPoints = $weightedMaxPts
            pass              = $pass
        })
    }

    # v2.0.0 (#225): aggregate per-pillar scores using weightedAwarded / weightedMaxPoints
    # so weight-3 rules count 3× a weight-1 rule; warnings automatically count 0.5× via
    # the fractional awarded points computed above.
    $pillarOrder  = @('Reliability', 'Security', 'Cost Optimization', 'Operational Excellence', 'Performance Efficiency')
    $pillarScores = New-Object System.Collections.ArrayList

    # Resolve v2.0.0 #225 score thresholds from waf-rules.json, with sensible fallbacks.
    $thresh = [ordered]@{ excellent = 80; good = 60; fair = 40; needsImprovement = 0 }
    if ($rulesData.scoreThresholds) {
        foreach ($k in @('excellent','good','fair','needsImprovement')) {
            if ($null -ne $rulesData.scoreThresholds.$k) { $thresh[$k] = [int]$rulesData.scoreThresholds.$k }
        }
    }

    $statusFor = {
        param([double]$s)
        if ($s -ge $thresh.excellent)        { return 'Excellent' }
        elseif ($s -ge $thresh.good)         { return 'Good' }
        elseif ($s -ge $thresh.fair)         { return 'Fair' }
        else                                  { return 'Needs Improvement' }
    }

    foreach ($pillar in $pillarOrder) {
        $pillarRules = @($ruleResults | Where-Object { $_.pillar -eq $pillar })
        $total       = $pillarRules.Count
        $passing     = @($pillarRules | Where-Object { $_.pass -eq $true }).Count
        # Hashtables don't expose keys as object properties for Measure-Object, so sum manually.
        $awarded = 0.0
        $maxPts  = 0.0
        foreach ($rr in $pillarRules) { $awarded += [double]$rr.weightedAwarded; $maxPts += [double]$rr.weightedMaxPoints }
        $score   = if ($maxPts -gt 0) { [int][math]::Round($awarded / $maxPts * 100) } else { 0 }
        $status      = & $statusFor $score
        $topFinding = @($pillarRules | Where-Object { $_.pass -eq $false } | Sort-Object {
            switch ($_.severity) { 'critical' { 0 } 'warning' { 1 } default { 2 } }
        } | Select-Object -First 1)

        [void]$pillarScores.Add([ordered]@{
            pillar       = $pillar
            total        = $total
            passing      = $passing
            score        = $score
            status       = $status
            topFinding   = if ($topFinding.Count -gt 0) { $topFinding[0].title } else { '-' }
            topSeverity  = if ($topFinding.Count -gt 0) { $topFinding[0].severity } else { '-' }
            weightedAwarded = [math]::Round($awarded, 2)
            weightedMax     = [math]::Round($maxPts, 2)
        })
    }

    $allRules     = @($ruleResults)
    $allPass      = @($allRules | Where-Object { $_.pass -eq $true }).Count
    $totalAwarded = 0.0
    $totalMax     = 0.0
    foreach ($rr in $allRules) { $totalAwarded += [double]$rr.weightedAwarded; $totalMax += [double]$rr.weightedMaxPoints }
    $overall      = if ($totalMax -gt 0) { [int][math]::Round($totalAwarded / $totalMax * 100) } else { 0 }

    return [ordered]@{
        pillarScores    = @($pillarScores)
        ruleResults     = @($ruleResults)
        scoreThresholds = $thresh
        summary         = [ordered]@{
            totalRules       = $allRules.Count
            passingRules     = $allPass
            failingRules     = $allRules.Count - $allPass
            overallScore     = $overall
            weightedAwarded  = [math]::Round($totalAwarded, 2)
            weightedMax      = [math]::Round($totalMax, 2)
            status           = & $statusFor $overall
        }
    }
}

function Invoke-RangerWafAssessmentCollector {
    <#
    .SYNOPSIS
        Queries Azure Advisor for WAF-relevant recommendations for the Azure Local cluster.
    .DESCRIPTION
        Calls Get-AzAdvisorRecommendation for the configured subscription, filters results
        to the resource group and HCI resource types, and maps Advisor categories to WAF
        pillars. The returned wafAssessment domain is stored in the manifest and used by
        Invoke-RangerWafRuleEvaluation at report-generation time.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [Parameter(Mandatory = $true)]
        $CredentialMap,

        [Parameter(Mandatory = $true)]
        [object]$Definition,

        [Parameter(Mandatory = $true)]
        [string]$PackageRoot
    )

    $fixture = Get-RangerCollectorFixtureData -Config $Config -CollectorId $Definition.Id
    if ($fixture) {
        return ConvertTo-RangerHashtable -InputObject $fixture
    }

    # Advisor category -> WAF pillar mapping
    $categoryMap = @{
        HighAvailability       = 'Reliability'
        Security               = 'Security'
        Cost                   = 'Cost Optimization'
        OperationalExcellence  = 'Operational Excellence'
        Performance            = 'Performance Efficiency'
    }

    $advisorRecommendations = @(
        Invoke-RangerSafeAction -Label 'Azure Advisor recommendations' -DefaultValue @() -ScriptBlock {
            Invoke-RangerAzureQuery -AzureCredentialSettings $CredentialMap.azure -ArgumentList @($Config.targets.azure.subscriptionId, $Config.targets.azure.resourceGroup) -ScriptBlock {
                param($SubscriptionId, $ResourceGroup)
                if (-not (Get-Command -Name Get-AzAdvisorRecommendation -ErrorAction SilentlyContinue)) { return @() }
                if ([string]::IsNullOrWhiteSpace($SubscriptionId)) { return @() }

                $allRecs = @(Get-AzAdvisorRecommendation -ErrorAction SilentlyContinue)
                $hciTypes = @('microsoft.azurestackhci/clusters', 'microsoft.hybridcompute/machines', 'microsoft.azurestackhci')

                # Filter to the configured resource group and HCI-relevant resource types where possible
                $filtered = @($allRecs | Where-Object {
                    $r = $_
                    $inRg    = [string]::IsNullOrWhiteSpace($ResourceGroup) -or ($r.ImpactedField -match $ResourceGroup -or $r.ResourceId -match $ResourceGroup)
                    $isHci   = $hciTypes | ForEach-Object { $r.ImpactedField -match $_ -or $r.ImpactedValue -match $_ } | Where-Object { $_ }
                    $inRg -or $isHci.Count -gt 0
                })

                # If nothing matched the filter, return the broader subscription results
                if ($filtered.Count -eq 0) { $filtered = @($allRecs | Select-Object -First 50) }

                @($filtered | ForEach-Object {
                    $r       = $_
                    $cat     = [string]$r.Category
                    [ordered]@{
                        id              = $r.Name
                        category        = $cat
                        wafPillar       = if ($cat -and $categoryMap.ContainsKey($cat)) { $categoryMap[$cat] } else { 'Operational Excellence' }
                        impact          = [string]$r.Impact
                        impactedField   = $r.ImpactedField
                        impactedValue   = $r.ImpactedValue
                        shortDescription = [string]$r.ShortDescription.Problem
                        remediation     = [string]$r.ShortDescription.Solution
                        score           = if ($null -ne $r.Score) { [double]$r.Score } else { 0 }
                        lastUpdated     = [string]$r.LastUpdated
                        resourceId      = $r.ResourceId
                    }
                })
            }
        }
    )

    # Group Advisor recommendations by WAF pillar
    $byPillar = New-Object System.Collections.ArrayList
    foreach ($pillar in @('Reliability', 'Security', 'Cost Optimization', 'Operational Excellence', 'Performance Efficiency')) {
        $pillarRecs = @($advisorRecommendations | Where-Object { $_.wafPillar -eq $pillar })
        [void]$byPillar.Add([ordered]@{
            pillar = $pillar
            count  = $pillarRecs.Count
            highImpactCount = @($pillarRecs | Where-Object { $_.impact -match 'High' }).Count
            recommendations = @($pillarRecs)
        })
    }

    $findings = New-Object System.Collections.ArrayList

    if ($advisorRecommendations.Count -eq 0) {
        [void]$findings.Add((New-RangerFinding -Severity informational -Title 'No Azure Advisor recommendations retrieved' -Description 'The WAF assessment collector could not retrieve Azure Advisor recommendations. This may be because the Az.Advisor module is not installed, no subscription context was provided, or no recommendations are currently active.' -CurrentState 'advisor data not collected' -Recommendation 'Install the Az.Advisor module and ensure a valid subscriptionId is configured to enable Advisor-based WAF recommendations.'))
    }

    $highImpactCount = @($advisorRecommendations | Where-Object { $_.impact -match 'High' }).Count
    if ($highImpactCount -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning -Title "Azure Advisor has $highImpactCount high-impact recommendation(s) for this environment" -Description "Azure Advisor returned $highImpactCount High-impact recommendation(s). Review the WAF Assessment section of the report for details." -CurrentState "$highImpactCount high-impact Advisor recommendations" -Recommendation 'Review each high-impact recommendation in the Azure portal Advisor blade and create work items to address before handoff.'))
    }

    return @{
        Status   = 'success'
        Domains  = @{
            wafAssessment = [ordered]@{
                advisorRecommendations = ConvertTo-RangerHashtable -InputObject $advisorRecommendations
                byPillar               = ConvertTo-RangerHashtable -InputObject $byPillar
                summary                = [ordered]@{
                    totalAdvisorRecommendations = $advisorRecommendations.Count
                    highImpactCount             = $highImpactCount
                    mediumImpactCount           = @($advisorRecommendations | Where-Object { $_.impact -match 'Medium' }).Count
                    lowImpactCount              = @($advisorRecommendations | Where-Object { $_.impact -match 'Low' }).Count
                    pillarBreakdown             = @($byPillar | ForEach-Object { [ordered]@{ pillar = $_.pillar; count = $_.count } })
                }
            }
        }
        Findings      = @($findings)
        Relationships = @()
        RawEvidence   = [ordered]@{
            advisorRecommendations = ConvertTo-RangerHashtable -InputObject $advisorRecommendations
        }
    }
}