Public/Get-GuerrillaScore.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-GuerrillaScore {
    <#
    .SYNOPSIS
        Returns the composite Guerrilla Security Score (0-100) with breakdown.
    .DESCRIPTION
        Computes a single 0-100 security score from audit findings, threat scan results,
        theater coverage, and trend data. Uses the active baseline profile (Default or K12)
        for component weights and thresholds.
 
        Score tiers:
          FORTRESS (90+) | DEFENDED POSITION (75-89) | CONTESTED GROUND (60-74)
          EXPOSED FLANK (40-59) | UNDER SIEGE (20-39) | OVERRUN (0-19)
 
    .PARAMETER AuditFindings
        Array of audit finding objects from Fortification/Reconnaissance theaters.
        If not provided, reads the latest state file.
    .PARAMETER ScanResults
        Array of scan result objects from Surveillance/Watchtower theaters.
        If not provided, reads the latest state files.
    .PARAMETER ProfileName
        Baseline profile to use: Default or K12. If not specified, uses the profile
        configured in Set-Safehouse, falling back to Default.
    .PARAMETER ConfigPath
        Override config file path.
    .EXAMPLE
        Get-GuerrillaScore
        Returns the composite score using latest scan data and configured profile.
    .EXAMPLE
        Get-GuerrillaScore -ProfileName K12
        Returns the score using K-12 education baseline weights.
    .EXAMPLE
        $findings = Invoke-Fortification -PassThru; Get-GuerrillaScore -AuditFindings $findings
        Computes score from specific audit findings.
    #>

    [CmdletBinding()]
    param(
        [PSCustomObject[]]$AuditFindings,
        [PSCustomObject[]]$ScanResults,

        [ValidateSet('Default', 'K12')]
        [string]$ProfileName,

        [Alias('RuntimeConfig')]
        [string]$ConfigPath
    )

    # Load config
    $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath }
    $config = $null
    if ($cfgPath -and (Test-Path $cfgPath)) {
        $config = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable
    }

    # Determine profile
    if (-not $ProfileName) {
        $ProfileName = $config.profile ?? 'Default'
    }

    $profileFile = switch ($ProfileName) {
        'K12'     { 'K12-Baseline.json' }
        default   { 'Default-Baseline.json' }
    }

    $profilePath = Join-Path $PSScriptRoot '../Data/Profiles' $profileFile
    $profile = $null
    if (Test-Path $profilePath) {
        $profile = Get-Content -Path $profilePath -Raw | ConvertFrom-Json -AsHashtable
    }

    # Load state data if not provided
    $dataDir = Get-PSGuerrillaDataRoot

    if (-not $AuditFindings) {
        # Try to load from latest state files
        $auditStateFiles = @()
        if (Test-Path $dataDir) {
            $auditStateFiles = @(Get-ChildItem -Path $dataDir -Filter '*.findings.json' -ErrorAction SilentlyContinue)
        }
        if ($auditStateFiles.Count -gt 0) {
            $AuditFindings = @()
            foreach ($f in $auditStateFiles) {
                try {
                    $data = Get-Content -Path $f.FullName -Raw | ConvertFrom-Json
                    $AuditFindings += @($data)
                } catch {
                    Write-Verbose "Failed to load findings from $($f.Name): $_"
                }
            }
        }
    }

    if (-not $ScanResults) {
        $stateFiles = @()
        if (Test-Path $dataDir) {
            $stateFiles = @(Get-ChildItem -Path $dataDir -Filter '*.state.json' -ErrorAction SilentlyContinue)
        }
        if ($stateFiles.Count -gt 0) {
            $ScanResults = @()
            foreach ($f in $stateFiles) {
                try {
                    $data = Get-Content -Path $f.FullName -Raw | ConvertFrom-Json
                    $ScanResults += $data
                } catch {
                    Write-Verbose "Failed to load state from $($f.Name): $_"
                }
            }
        }
    }

    # Load previous score for trend
    $previousScore = -1
    $scoreHistoryPath = Join-Path $dataDir 'guerrilla-score-history.json'
    if (Test-Path $scoreHistoryPath) {
        try {
            $history = Get-Content -Path $scoreHistoryPath -Raw | ConvertFrom-Json -AsHashtable
            if ($history.lastScore -is [double] -or $history.lastScore -is [int]) {
                $previousScore = [double]$history.lastScore
            }
        } catch { }
    }

    # Calculate composite score
    $scoreResult = Get-GuerrillaScoreCalculation `
        -AuditFindings $AuditFindings `
        -ScanResults $ScanResults `
        -PreviousScore $previousScore `
        -Profile $profile

    # Save score history
    if (-not (Test-Path $dataDir)) { New-Item -Path $dataDir -ItemType Directory -Force | Out-Null }
    @{
        lastScore    = $scoreResult.Score
        lastLabel    = $scoreResult.Label
        timestamp    = [datetime]::UtcNow.ToString('o')
        profileUsed  = $ProfileName
    } | ConvertTo-Json -Depth 3 | Set-Content -Path $scoreHistoryPath -Encoding UTF8

    return $scoreResult
}