internal/Audit/Test-GuerrillaPrerequisite.ps1
|
# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution # Central prerequisite gate for the dispatch loop. # # A check definition may declare an optional `requires` block: # # "requires": { # "collectors": ["Users"], # AuditData.Errors[key] must be clear # "dataPaths": ["Users"] # dotted path must resolve to non-empty # } # # The dispatch loop calls this BEFORE invoking the check function. When a # requirement is unmet the loop emits a single, consistently-worded SKIP finding # flagged NotAssessed, instead of relying on every check to hand-roll its own # "empty data => SKIP" guard. This is the engine-level enforcement of the rule # that absence of evidence is never scored as compliance: a check whose evidence # was not collected physically cannot run, so it cannot accidentally PASS. # # Checks without a `requires` block are unaffected (gate returns Met = $true), so # this is a no-op for the other definitions until they are migrated. function Test-GuerrillaPrerequisite { [CmdletBinding()] param( # Hashtable or PSCustomObject check definition (may carry a `requires` block). [Parameter(Mandatory)] $CheckDefinition, [Parameter(Mandatory)] [hashtable]$AuditData ) $requires = $CheckDefinition.requires if (-not $requires) { return [pscustomobject]@{ Met = $true; Reason = ''; Failed = $null } } # 1) Collector health. A named collector whose key is present in AuditData.Errors # failed to gather evidence, so anything downstream of it is unknown. foreach ($c in @($requires.collectors)) { if ([string]::IsNullOrWhiteSpace($c)) { continue } $err = if ($AuditData.ContainsKey('Errors') -and $AuditData.Errors) { $AuditData.Errors[$c] } else { $null } if ($err) { return [pscustomobject]@{ Met = $false Failed = "collector:$c" Reason = "Not Assessed: the '$c' collector reported an error ($err), so this check has no evidence to evaluate. Absence of evidence is not compliance — re-run once the collector succeeds." } } } # 2) Data presence. Every declared path must resolve to a non-empty value. foreach ($p in @($requires.dataPaths)) { if ([string]::IsNullOrWhiteSpace($p)) { continue } $val = Resolve-GuerrillaDataPath -AuditData $AuditData -Path $p if (Test-GuerrillaValueEmpty $val) { return [pscustomobject]@{ Met = $false Failed = "dataPath:$p" Reason = "Not Assessed: required data '$p' was not collected (missing or empty), so this check cannot run. Absence of evidence is not compliance." } } } return [pscustomobject]@{ Met = $true; Reason = ''; Failed = $null } } # Walk a dotted path (e.g. "M365Services.Exchange.MalwarePolicies") into the # AuditData graph, tolerating both hashtable and PSCustomObject nodes. Returns # $null the moment a segment is missing. function Resolve-GuerrillaDataPath { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData, [Parameter(Mandatory)] [string]$Path ) $node = $AuditData foreach ($seg in ($Path -split '\.')) { if ($null -eq $node) { return $null } if ($node -is [System.Collections.IDictionary]) { if (-not $node.Contains($seg)) { return $null } $node = $node[$seg] } elseif ($node.PSObject.Properties[$seg]) { $node = $node.$seg } else { return $null } } return $node } # "Empty" for prerequisite purposes: $null, blank string, empty dictionary, or # empty list. A scalar (number, bool, non-blank string) counts as present. # Deliberately does NOT treat @($null) specially — callers pass the resolved # node, not a splatted collection, so the @($null).Count == 1 trap never arises. function Test-GuerrillaValueEmpty { [CmdletBinding()] param([Parameter(Mandatory = $false)] $Value) if ($null -eq $Value) { return $true } if ($Value -is [string]) { return [string]::IsNullOrWhiteSpace($Value) } if ($Value -is [System.Collections.IDictionary]) { return $Value.Count -eq 0 } if ($Value -is [System.Collections.IList]) { return $Value.Count -eq 0 } return $false } |