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-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' }
        }
    }

    $ruleResults = New-Object System.Collections.ArrayList

    foreach ($rule in $rulesData.rules) {
        $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 }
        }

        [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
            pass           = $pass
        })
    }

    # Aggregate per-pillar scores
    $pillarOrder  = @('Reliability', 'Security', 'Cost Optimization', 'Operational Excellence', 'Performance Efficiency')
    $pillarScores = New-Object System.Collections.ArrayList
    foreach ($pillar in $pillarOrder) {
        $pillarRules = @($ruleResults | Where-Object { $_.pillar -eq $pillar })
        $total       = $pillarRules.Count
        $passing     = @($pillarRules | Where-Object { $_.pass -eq $true }).Count
        $score       = if ($total -gt 0) { [int][math]::Round($passing / $total * 100) } else { 0 }
        $status      = switch ($score) {
            { $_ -ge 90 } { 'Excellent' }
            { $_ -ge 75 } { 'Good' }
            { $_ -ge 50 } { 'Needs Attention' }
            default        { 'At Risk' }
        }
        $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 { '-' }
        })
    }

    $allRules  = @($ruleResults)
    $allPass   = @($allRules | Where-Object { $_.pass -eq $true }).Count
    $overall   = if ($allRules.Count -gt 0) { [int][math]::Round($allPass / $allRules.Count * 100) } else { 0 }

    return [ordered]@{
        pillarScores    = @($pillarScores)
        ruleResults     = @($ruleResults)
        summary         = [ordered]@{
            totalRules    = $allRules.Count
            passingRules  = $allPass
            failingRules  = $allRules.Count - $allPass
            overallScore  = $overall
            status        = switch ($overall) {
                { $_ -ge 90 } { 'Excellent' }
                { $_ -ge 75 } { 'Good' }
                { $_ -ge 50 } { 'Needs Attention' }
                default        { 'At Risk' }
            }
        }
    }
}

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
        }
    }
}