Private/Measure-KrbRiskLevel.ps1

#Requires -Version 7.4

function Measure-KrbRiskLevel {
    <#
    .SYNOPSIS
        Reduces a set of findings to a single risk level and score

    .DESCRIPTION
        Produces two summary values from a principal's findings, because neither alone
        answers the question an administrator is asking.

        - Level is the highest severity present. It answers "how bad is the worst thing here",
          which is what decides whether this principal blocks the change.
        - Score is the capped sum of finding weights. It answers "how much is wrong here",
          which is what orders a remediation backlog when a hundred principals all come back
          Critical.

        Deriving Level from the score instead - the obvious simplification - loses the
        distinction. Four Medium findings and one Critical finding can reach the same total,
        and treating them as equivalent puts a tidy-up task ahead of an outage.

        Blast radius scales the score but never the level. A principal with one critical
        finding and 300 dependent clients and a principal with one critical finding and one
        dependent client are both Critical - both will break - but the first should be worked
        first. Letting client count raise the level would let a widely-used but healthy
        service outrank a broken but obscure one.

    .PARAMETER Finding
        [System.Object[]] (Mandatory, No Pipeline Support)

        The findings to summarise. An empty set is valid and yields a level of None.

    .PARAMETER ClientCount
        [System.Int32] (Optional, No Pipeline Support)

        Number of distinct clients depending on the principal. Applies a logarithmic uplift
        to the score. Logarithmic rather than linear because the difference between one
        client and ten is a change in kind, while the difference between three hundred and
        three thousand is not - both are "the whole estate".

    .EXAMPLE
        PS> Measure-KrbRiskLevel -Finding $findings -ClientCount 12

        DESCRIPTION: Summarises a principal's findings with its blast radius
        OUTPUT: An object carrying Level, Score and the per-severity counts
        USE CASE: Called once per principal by Get-KrbEtypeRisk

    .OUTPUTS
        KrbEtypeInsight.RiskSummary

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/

        TROUBLESHOOTING:
        - Interpreting risk scores: .\Troubleshooting\Common\Risk-Scoring.md
    #>

    [CmdletBinding()]
    [OutputType('KrbEtypeInsight.RiskSummary')]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Finding,

        [Parameter()]
        [ValidateRange(0, [int]::MaxValue)]
        [int]$ClientCount = 0
    )

    $counts = @{ Critical = 0; High = 0; Medium = 0; Low = 0; Info = 0 }
    $baseScore = 0

    foreach ($item in $Finding) {
        if ($counts.ContainsKey($item.Severity)) { $counts[$item.Severity]++ }
        $baseScore += $item.Weight
    }

    $level = if ($counts.Critical -gt 0)   { 'Critical' }
             elseif ($counts.High -gt 0)   { 'High' }
             elseif ($counts.Medium -gt 0) { 'Medium' }
             elseif ($counts.Low -gt 0)    { 'Low' }
             elseif ($counts.Info -gt 0)   { 'Info' }
             else                          { 'None' }

    # Log base 10 of the client count, scaled. One client contributes nothing, ten contribute
    # 10, a hundred contribute 20. A principal with no findings stays at zero regardless of
    # how many clients use it - blast radius multiplies a problem, it does not create one.
    $radiusUplift = 0
    if ($ClientCount -gt 1 -and $baseScore -gt 0) {
        $radiusUplift = [int][Math]::Round([Math]::Log10($ClientCount) * 10)
    }

    [PSCustomObject]@{
        PSTypeName    = 'KrbEtypeInsight.RiskSummary'
        Level         = $level
        Score         = [Math]::Min(100, $baseScore + $radiusUplift)
        BaseScore     = $baseScore
        RadiusUplift  = $radiusUplift
        CriticalCount = $counts.Critical
        HighCount     = $counts.High
        MediumCount   = $counts.Medium
        LowCount      = $counts.Low
        InfoCount     = $counts.Info
    }
}