src/assess/engine/Invoke-Rule.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Evaluate a single rule against the collect object, returning a finding.
 
.NOTES
    Supports the seven assert types. Tracks ADO Story AB#5030.
 
    AB#6826 (Feature AB#6749, Epic AB#6454) added an eighth, orthogonal concept: an optional
    `assert.gate` JSONPath. When present, the rule's Status is 'NotAssessed' (and the normal
    query/assert evaluation never runs) whenever the gate resolves to NO matches at all, OR
    resolves to a single scalar boolean token whose value is `false` -- for a data source that
    is gated behind a permission system Scout's ordinary Reader role does not satisfy (the
    FinOps EA/MCA billing gate, Azure DevOps access not granted), a `countEquals: 0` or
    `exists` assert cannot tell "the source was blocked" apart from "the source was checked
    and found clean", and collapsing the two into a Pass or a Fail is exactly the false read
    AB#6793 already fixed once for Azure Policy compliance state. `gate` is evaluated with the
    SAME Resolve-JsonPath a rule's own `query` uses -- write it as a plain scalar path to a
    boolean field the collect pipeline computes (`$.finops.available`, `$.devops.available`),
    NOT a `[?()]` array filter: Newtonsoft JSONPath's `[?()]` iterates an array's ELEMENTS, and
    `finops`/`devops` are single objects, not arrays, so `$.finops[?(@.available == true)]`
    silently matches nothing in EITHER state -- found by this feature's own manual gate test,
    not by a live run.
#>

function Invoke-Rule {
    param(
        [Parameter(Mandatory)] $Rule,
        [Parameter(Mandatory)] $Collect,
        [string] $Area,
        [string] $Framework
    )

    $status = 'Unknown'; $evidenceCount = 0; $evidence = @()

    # AB#6892. Initialised here, not at the point of use, because the early `return`s below
    # (gate failure, query failure, not-assessed) build their own finding object and every one of
    # them must still be able to say what it was going to look for. $null means "not applicable
    # to this rule", which is distinct from 0.
    $denominator = $null

    # ---- AB#6826: optional gate, checked before manual/query evaluation ----
    $gatePath = $null
    if ($Rule.assert -is [hashtable]) {
        if ($Rule.assert.ContainsKey('gate')) { $gatePath = $Rule.assert.gate }
    }
    elseif ($Rule.assert -and $Rule.assert.PSObject.Properties['gate']) {
        $gatePath = $Rule.assert.gate
    }
    if (-not [string]::IsNullOrWhiteSpace($gatePath)) {
        $gateMatches = $null
        # NOT wrapped in @(): Resolve-JsonPath already returns its (possibly empty) array via
        # `Write-Output -NoEnumerate`, exactly like every other Resolve-JsonPath call in this
        # file. Wrapping it again here nests that array inside a further one-element array, so
        # `.Count` is never 0 even on a genuinely empty result -- found by this feature's own
        # gate test, not by a live run.
        try { $gateMatches = Resolve-JsonPath -InputObject $Collect -Path $gatePath }
        catch {
            Write-Warning "Rule $($Rule.id): gate query '$gatePath' failed: $_"
            return [pscustomobject]@{
                Id = $Rule.id; Title = $Rule.title; Framework = $Framework; Area = $Area
                Severity = $Rule.severity; Status = 'Error'; EvidenceCount = 0; Evidence = @()
                Remediation = $Rule.remediation; Manual = [bool]$Rule.manual
            }
        }
        $gateOpen = $true
        if (@($gateMatches).Count -eq 0) {
            $gateOpen = $false
        }
        elseif (@($gateMatches).Count -eq 1) {
            # A single scalar boolean token (the intended shape: `$.finops.available`,
            # `$.devops.available`) closes the gate when its value is exactly `false`. Any
            # other single-match shape (a row, a string, ...) is treated as "present" -- the
            # gate is a data-availability check, not a second assert.
            try { if ($gateMatches[0].ToObject([bool]) -eq $false) { $gateOpen = $false } }
            catch { }   # not a boolean token -- presence alone means the gate is open
        }
        if (-not $gateOpen) {
            return [pscustomobject]@{
                Id = $Rule.id; Title = $Rule.title; Framework = $Framework; Area = $Area
                Severity = $Rule.severity; Status = 'NotAssessed'; EvidenceCount = 0; Evidence = @()
                Remediation = $Rule.remediation; Manual = [bool]$Rule.manual
            }
        }
    }

    # A rule carries EITHER a `query` (one dataset, filtered) or a `join` (two datasets,
    # correlated) -- never both. `join` is read through the same shape-agnostic accessor the rest
    # of this function uses, because ConvertFrom-Yaml hands back a Hashtable while test fixtures
    # build a pscustomobject, and dotting a missing key throws under StrictMode (AB#6835).
    $hasJoin = if ($Rule -is [hashtable]) { $Rule.ContainsKey('join') -and $null -ne $Rule.join }
               elseif ($Rule -is [System.Collections.IDictionary]) { $Rule.Contains('join') -and $null -ne $Rule['join'] }
               else { $null -ne $Rule.PSObject.Properties['join'] -and $null -ne $Rule.join }

    if ($Rule.manual -or $Rule.assert.type -eq 'manual') {
        # pre-fill with any evidence the scan DID find, then hand to the human
        if ($Rule.query) {
            $evidence = Resolve-JsonPath -InputObject $Collect -Path $Rule.query
            $evidenceCount = $evidence.Count
        }
        $status = 'Manual'
    }
    else {
        try {
            # Assigned INSIDE each branch, not from the `if` as an expression. `$x = if (...) { @() }`
            # returns $null, not an empty array -- the if's output stream enumerates and an empty
            # array enumerates to nothing -- and the very next line reads `.Count`, which then
            # throws under StrictMode. A join that legitimately found no unmatched rows (the PASS
            # case, and the common one) hit that on every rule.
            $matches = $null
            if ($hasJoin) {
                $matches = @(Resolve-RuleJoin -Rule $Rule -Collect $Collect)
            } else {
                $matches = Resolve-JsonPath -InputObject $Collect -Path $Rule.query
            }
        }
        catch {
            # A query that threw (unsupported/invalid JSONPath, or a malformed join block) is an
            # Error, never a silent Pass on countEquals:0 (AB#5083). Surface it so it's visible.
            Write-Warning "Rule $($Rule.id): $(if ($hasJoin) { 'join' } else { "query '$($Rule.query)'" }) failed: $_"
            return [pscustomobject]@{
                Id = $Rule.id; Title = $Rule.title; Framework = $Framework; Area = $Area
                Severity = $Rule.severity; Status = 'Error'; EvidenceCount = 0; Evidence = @()
                Remediation = $Rule.remediation; Manual = [bool]$Rule.manual
            }
        }
        $evidenceCount = $matches.Count
        $evidence = $matches | Select-Object -First 25    # cap evidence payload
        # ConvertFrom-Yaml returns `assert:` as a Hashtable (test fixtures often use a
        # pscustomobject instead), and 'exists'/'notExists' rules legitimately omit a
        # `value:` key. Accessing a missing key/property via dot-notation throws
        # PropertyNotFoundException under Set-StrictMode -Version Latest, so only read
        # .value when it's actually present — the exists/notExists cases below never
        # reference $v. Handle both Hashtable and pscustomobject assert shapes.
        $v = $null
        if ($Rule.assert -is [hashtable]) {
            if ($Rule.assert.ContainsKey('value')) { $v = $Rule.assert.value }
        }
        elseif ($Rule.assert.PSObject.Properties['value']) {
            $v = $Rule.assert.value
        }

        switch ($Rule.assert.type) {
            'countGreaterThan'  { $status = ($evidenceCount -gt  $v) ? 'Pass' : 'Fail' }
            'countEquals'       { $status = ($evidenceCount -eq  $v) ? 'Pass' : 'Fail' }
            'countLessThan'     { $status = ($evidenceCount -lt  $v) ? 'Pass' : 'Fail' }
            'exists'            { $status = ($evidenceCount -gt   0) ? 'Pass' : 'Fail' }
            'notExists'         { $status = ($evidenceCount -eq   0) ? 'Pass' : 'Fail' }
            'percentageAtLeast' {
                $denom = (Resolve-JsonPath -InputObject $Collect -Path $Rule.assert.denominatorQuery).Count
                # AB#6892: surfaced on the finding so a renderer can say "17 of 198", which is the
                # supporting number the reference deliverable carries on every risk row. Without
                # it the reader gets a percentage with nothing behind it.
                $denominator = $denom
                # No denominator = nothing collected for this dimension -> Unknown,
                # NOT a 0% Fail, which would be misleading (AB#5085).
                if ($denom -le 0) { $status = 'Unknown' }
                else {
                    $pct = $evidenceCount / $denom * 100
                    $status = ($pct -ge $v) ? 'Pass' : (($pct -gt 0) ? 'Partial' : 'Fail')
                }
            }
            default {
                Write-Warning "Rule $($Rule.id): unknown assert type '$($Rule.assert.type)'"
                $status = 'Error'
            }
        }
    }

    # AB#6892 -- WHAT WAS SEARCHED, always, even when nothing was found.
    #
    # Phase 0 measured that 42 of 57 FAILING controls carried zero evidence, and no report named a
    # single Azure resource across three real tenants. That is not a renderer defect. An `exists`
    # rule fails precisely BECAUSE its query returned nothing, so its evidence list is empty by
    # construction -- there is no resource to name, and there never will be.
    #
    # What makes such a finding actionable is not a resource list but the SCOPE: what was looked
    # for, where, and how many candidates existed. "No Purview account was found in any of the 9
    # subscriptions scanned" is actionable. "Data governance coverage: Fail" is decoration.
    #
    # So every finding now carries the query it ran and the assertion it applied, and the
    # percentage rules carry their denominator. Renderers can state the scope of a nil result
    # instead of rendering an empty table.
    $searchedPath = if ($hasJoin) { '(join)' } else { [string]$Rule.query }
    $assertType = if ($Rule.assert) { [string]$Rule.assert.type } else { $null }

    [pscustomobject]@{
        Id            = $Rule.id
        Title         = $Rule.title
        Framework     = $Framework
        Area          = $Area
        Severity      = $Rule.severity
        Status        = $status
        EvidenceCount = $evidenceCount
        Evidence      = $evidence
        SearchedPath  = $searchedPath
        AssertType    = $assertType
        Denominator   = $denominator
        Remediation   = $Rule.remediation
        Manual        = [bool]$Rule.manual
    }
}