internal/Audit/Read-GuerrillaDeviation.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 # Read and validate a local deviation-overlay file (guerrilla-deviations.json). # # A deviation file is the customer's own, source-controllable record of # accepted exceptions to the shipped checks. It lives OUTSIDE the check universe # so it survives module updates, and it never edits the check definitions. Shape: # # { # "version": "1.0", # "deviations": [ # { # "checkId": "GTRADE-004", # "type": "accept-risk", // accept-risk | suppress | annotate | expect-fail # "justification": "6 break-glass super admins are intentional", # "approvedBy": "jim@contoso.com", # "expires": "2026-12-31", // optional; an expired deviation stops applying # "scope": { "orgUnitPath": "/" } // optional; narrows to one OU # } # ] # } # # Returns a normalized hashtable[] (empty array if the file has no deviations). # Throws on structural problems — a malformed exception file must fail loudly, # never be half-applied. function Read-GuerrillaDeviation { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) if (-not (Test-Path -LiteralPath $Path)) { throw "Deviation file not found: $Path" } $raw = Get-Content -LiteralPath $Path -Raw try { $cfg = $raw | ConvertFrom-Json -AsHashtable } catch { throw "Deviation file is not valid JSON ($Path): $($_.Exception.Message)" } if ([string]::IsNullOrWhiteSpace("$($cfg.version)")) { throw "Deviation file missing 'version' field ($Path). Is this a guerrilla-deviations.json file?" } $validTypes = @('accept-risk', 'suppress', 'annotate', 'expect-fail') $out = [System.Collections.Generic.List[hashtable]]::new() $i = 0 foreach ($d in @($cfg.deviations)) { $i++ if ([string]::IsNullOrWhiteSpace("$($d.checkId)")) { throw "Deviation #$i in $Path is missing 'checkId'." } $type = "$($d.type)".Trim().ToLowerInvariant() if ($type -notin $validTypes) { throw "Deviation for '$($d.checkId)' has unknown type '$($d.type)'. Valid types: $($validTypes -join ', ')." } # accept-risk and suppress change the score / hide a real finding, so they # must carry a written reason. annotate/expect-fail are lower-stakes. if ($type -in @('accept-risk', 'suppress') -and [string]::IsNullOrWhiteSpace("$($d.justification)")) { throw "Deviation for '$($d.checkId)' (type '$type') requires a non-empty 'justification'." } $expires = $null if (-not [string]::IsNullOrWhiteSpace("$($d.expires)")) { $parsed = [datetime]::MinValue $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal if (-not [datetime]::TryParse("$($d.expires)", [cultureinfo]::InvariantCulture, $styles, [ref]$parsed)) { throw "Deviation for '$($d.checkId)' has an unparseable 'expires' value '$($d.expires)' (use an ISO date, e.g. 2026-12-31)." } $expires = $parsed } $ou = $null if ($d.scope -and -not [string]::IsNullOrWhiteSpace("$($d.scope.orgUnitPath)")) { $ou = "$($d.scope.orgUnitPath)" } $out.Add(@{ CheckId = "$($d.checkId)".Trim() Type = $type Justification = "$($d.justification)" ApprovedBy = "$($d.approvedBy)" Expires = $expires OrgUnitPath = $ou }) } # Typed array contract. A single-deviation result unrolls to a scalar on # return (PowerShell), but every consumer either casts to [hashtable[]] # (Merge-GuerrillaDeviation) or wraps with @(), so 0/1/n all behave. return [hashtable[]]$out.ToArray() } |