Public/Get-GuerrillaMaturity.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Get-GuerrillaMaturity {
    <#
    .SYNOPSIS
        Computes a CMMI-style 1-5 security-maturity level from audit findings.

    .DESCRIPTION
        Turns a set of PSGuerrilla audit findings into an executive-grade maturity rating
        (Active Directory, Google Workspace, or Entra/M365 — anything that produces findings).

        Like a maturity model should, the WORST unmet control anchors the score: a single
        open Critical caps the whole environment at Level 1 no matter how much else passes.
        This is deliberately stricter than an averaged 0-100 score — it answers "how mature
        is this estate" the way an auditor or a board reads it, and it tells you exactly which
        findings are holding you at the current level (the anchors) so advancement is concrete.

        Level model (CMMI-aligned):
          1 Initial - an open Critical exposure exists
          2 Managed - no Criticals, but open High findings
          3 Defined - no High, but open Medium findings
          4 Quantitatively Managed - only Low findings / warnings remain
          5 Optimized - no open failures or warnings

        Anchoring: FAIL caps by severity (Critical->1, High->2, Medium->3, Low->4); any WARN
        caps at 4; PASS / SKIP / ERROR never cap. The overall level is the lowest cap across
        all findings; per-category levels are computed the same way.

    .PARAMETER Findings
        Audit findings (e.g. (Invoke-Reconnaissance).Findings). Accepts pipeline input.

    .PARAMETER Theater
        Optional label carried onto the result (e.g. 'ActiveDirectory').

    .EXAMPLE
        (Invoke-Reconnaissance).Findings | Get-GuerrillaMaturity -Theater ActiveDirectory

    .EXAMPLE
        $m = Get-GuerrillaMaturity -Findings $result.Findings
        "AD maturity: Level $($m.OverallLevel) ($($m.OverallLabel))"
    #>

    [CmdletBinding()]
    [OutputType('PSGuerrilla.Maturity')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [AllowNull()]
        [object[]]$Findings,

        [string]$Theater = ''
    )

    begin { $all = [System.Collections.Generic.List[object]]::new() }
    process { foreach ($f in $Findings) { if ($null -ne $f) { $all.Add($f) } } }
    end {
        $findings = @($all)
        $levelLabels = @{ 1 = 'Initial'; 2 = 'Managed'; 3 = 'Defined'; 4 = 'Quantitatively Managed'; 5 = 'Optimized' }

        # The maturity ceiling a single finding imposes (lower = worse). 5 == imposes no cap.
        $capFor = {
            param($f)
            $status = "$($f.Status)"
            if ($status -notin @('FAIL', 'WARN')) { return 5 }
            if ($status -eq 'WARN') { return 4 }
            switch -regex ("$($f.Severity)") {
                '(?i)^crit' { return 1 }
                '(?i)^high' { return 2 }
                '(?i)^med'  { return 3 }
                '(?i)^low'  { return 4 }
                default     { return 3 }   # a FAIL with no/unknown severity -> Defined cap
            }
        }

        $capped = foreach ($f in $findings) { [PSCustomObject]@{ Finding = $f; Cap = (& $capFor $f) } }
        $capped = @($capped)

        $capping = @($capped | Where-Object { $_.Cap -lt 5 })
        # Cast to [int] — Measure-Object -Minimum returns a double, which misses the int hashtable keys.
        $overallLevel = if ($capping.Count -gt 0) { [int]($capping.Cap | Measure-Object -Minimum).Minimum } else { 5 }
        $anchors = @($capped | Where-Object { $_.Cap -eq $overallLevel -and $_.Cap -lt 5 } | ForEach-Object { $_.Finding })

        # Per-category maturity (same worst-anchors logic within each category)
        $categoryLevels = [ordered]@{}
        $cats = @($findings | ForEach-Object { "$($_.Category)" } | Where-Object { $_ } | Select-Object -Unique | Sort-Object)
        foreach ($cat in $cats) {
            $catCapped = @($capped | Where-Object { "$($_.Finding.Category)" -eq $cat })
            $catCap = @($catCapped | Where-Object { $_.Cap -lt 5 })
            $lvl = if ($catCap.Count -gt 0) { [int]($catCap.Cap | Measure-Object -Minimum).Minimum } else { 5 }
            $categoryLevels[$cat] = [PSCustomObject]@{
                Category = $cat
                Level    = $lvl
                Label    = $levelLabels[$lvl]
                Anchors  = @($catCapped | Where-Object { $_.Cap -eq $lvl -and $_.Cap -lt 5 } | ForEach-Object { $_.Finding.CheckId })
            }
        }

        # What to fix to climb one level (the current anchors)
        $blockers = if ($overallLevel -lt 5) {
            @($anchors | ForEach-Object { "$($_.CheckId): $($_.CheckName)" } | Select-Object -Unique)
        } else { @() }

        [PSCustomObject]@{
            PSTypeName        = 'PSGuerrilla.Maturity'
            Theater           = $Theater
            OverallLevel      = $overallLevel
            OverallLabel      = $levelLabels[$overallLevel]
            NextLevel         = if ($overallLevel -lt 5) { $overallLevel + 1 } else { $null }
            NextLevelBlockers = $blockers
            Anchors           = $anchors
            AnchorCheckIds    = @($anchors | ForEach-Object { $_.CheckId } | Select-Object -Unique)
            CategoryLevels    = $categoryLevels
            Summary           = [PSCustomObject]@{
                CriticalFail = @($findings | Where-Object { $_.Status -eq 'FAIL' -and "$($_.Severity)" -match '(?i)^crit' }).Count
                HighFail     = @($findings | Where-Object { $_.Status -eq 'FAIL' -and "$($_.Severity)" -match '(?i)^high' }).Count
                MediumFail   = @($findings | Where-Object { $_.Status -eq 'FAIL' -and "$($_.Severity)" -match '(?i)^med' }).Count
                LowFail      = @($findings | Where-Object { $_.Status -eq 'FAIL' -and "$($_.Severity)" -match '(?i)^low' }).Count
                Warn         = @($findings | Where-Object { $_.Status -eq 'WARN' }).Count
                Pass         = @($findings | Where-Object { $_.Status -eq 'PASS' }).Count
            }
        }
    }
}