feat(llm-security)!: v7.0.0 commit 1 — severity-dominated log-scaled risk score

Replace sum-and-cap formula (every non-trivial scan → 100/Extreme) with
severity-dominated, log-scaled-within-tier model. Discriminates actual
risk: 1 critical = 80, 2 critical = 86, 17 high = 65. Hyperframes-class
rendering codebases no longer collapse to Extreme just from shader noise.

Changes:
- scanners/lib/severity.mjs: new riskScore() v2; keep riskScoreV1() for
  reference; riskBand() cutoffs aligned (14/39/64/84).
- scanners/posture-scanner.mjs: delete inline duplicate formula, import
  riskScore/riskBand/verdict from severity.mjs. Single source of truth.

Breaking: aggregate.risk_score semantics change. Batched with entropy
suppression (Commit 2+) under v7.0.0 bump in Commit 6. Do not release
individually — JSON consumers depend on scoring band stability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-04-19 22:00:29 +02:00
commit d83424a782
2 changed files with 60 additions and 28 deletions

View file

@ -9,20 +9,56 @@ export const SEVERITY = Object.freeze({
INFO: 'info',
});
const SEVERITY_WEIGHTS = { critical: 25, high: 10, medium: 4, low: 1, info: 0 };
// Legacy weights — used only by riskScoreV1() for backwards-compat reference.
const SEVERITY_WEIGHTS_V1 = { critical: 25, high: 10, medium: 4, low: 1, info: 0 };
/**
* Calculate aggregate risk score from severity counts.
* Calculate aggregate risk score from severity counts (v2 model v7.0.0+).
*
* Severity-dominated, log-scaled within tier. Replaces the v1 sum-and-cap
* formula which collapsed every non-trivial scan to 100/Extreme regardless
* of actual risk distribution.
*
* Tiers:
* Critical present 70-95 (1=80, 2=86, 4=90, 10=95)
* High only 40-65 (1=48, 5=60, 17=65)
* Medium only 15-35 (1=20, 5=28, 50=33)
* Low only 1-11 (1=4, 10=11)
* None 0
*
* @param {{ critical: number, high: number, medium: number, low: number, info: number }} counts
* @returns {number} 0-100 capped score
* @returns {number} 0-100 risk score
*/
export function riskScore(counts) {
const critical = counts.critical || 0;
const high = counts.high || 0;
const medium = counts.medium || 0;
const low = counts.low || 0;
let base;
if (critical > 0) base = 70 + Math.min(25, Math.log2(critical + 1) * 10);
else if (high > 0) base = 40 + Math.min(25, Math.log2(high + 1) * 8);
else if (medium > 0) base = 15 + Math.min(20, Math.log2(medium + 1) * 5);
else if (low > 0) base = 1 + Math.min(10, Math.log2(low + 1) * 3);
else base = 0;
return Math.round(Math.min(100, base));
}
/**
* Legacy v1 risk score formula kept for diff/comparison only.
* Not exported in production paths; reference for CI re-calibration.
*
* @param {{ critical: number, high: number, medium: number, low: number, info: number }} counts
* @returns {number} 0-100 capped score (sum-and-cap model)
*/
export function riskScoreV1(counts) {
const raw =
(counts.critical || 0) * SEVERITY_WEIGHTS.critical +
(counts.high || 0) * SEVERITY_WEIGHTS.high +
(counts.medium || 0) * SEVERITY_WEIGHTS.medium +
(counts.low || 0) * SEVERITY_WEIGHTS.low +
(counts.info || 0) * SEVERITY_WEIGHTS.info;
(counts.critical || 0) * SEVERITY_WEIGHTS_V1.critical +
(counts.high || 0) * SEVERITY_WEIGHTS_V1.high +
(counts.medium || 0) * SEVERITY_WEIGHTS_V1.medium +
(counts.low || 0) * SEVERITY_WEIGHTS_V1.low +
(counts.info || 0) * SEVERITY_WEIGHTS_V1.info;
return Math.min(raw, 100);
}
@ -41,14 +77,21 @@ export function verdict(counts) {
/**
* Map a 0-100 risk score to a human-readable risk band.
* Cutoffs aligned to v2 riskScore() tier structure (v7.0.0+):
* 0-14 Low (no findings, or low-tier only)
* 15-39 Medium (medium-tier dominant)
* 40-64 High (high-tier dominant)
* 65-84 Critical (1 critical, or many high)
* 85-100 Extreme (multiple critical findings)
*
* @param {number} score - 0-100 risk score
* @returns {'Low' | 'Medium' | 'High' | 'Critical' | 'Extreme'}
*/
export function riskBand(score) {
if (score <= 20) return 'Low';
if (score <= 40) return 'Medium';
if (score <= 60) return 'High';
if (score <= 80) return 'Critical';
if (score <= 14) return 'Low';
if (score <= 39) return 'Medium';
if (score <= 64) return 'High';
if (score <= 84) return 'Critical';
return 'Extreme';
}

View file

@ -13,7 +13,7 @@ import { readFile, readdir, stat, access } from 'node:fs/promises';
import { join, resolve, relative, extname } from 'node:path';
import { homedir } from 'node:os';
import { scanForInjection } from './lib/injection-patterns.mjs';
import { gradeFromPassRate, SEVERITY } from './lib/severity.mjs';
import { gradeFromPassRate, riskScore, riskBand, verdict, SEVERITY } from './lib/severity.mjs';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
// ---------------------------------------------------------------------------
@ -1489,21 +1489,10 @@ export async function scan(targetPath) {
const grade = gradeFromPassRate(passRate, failsInCritCats, counts.critical);
// Risk score
const riskScoreValue = Math.min(
counts.critical * 25 + counts.high * 10 + counts.medium * 4 + counts.low * 1,
100,
);
const riskBandValue =
riskScoreValue <= 20 ? 'Low' :
riskScoreValue <= 40 ? 'Medium' :
riskScoreValue <= 60 ? 'High' :
riskScoreValue <= 80 ? 'Critical' : 'Extreme';
const verdictValue =
counts.critical >= 1 || riskScoreValue >= 61 ? 'BLOCK' :
counts.high >= 1 || riskScoreValue >= 21 ? 'WARNING' : 'ALLOW';
// Risk score (delegated to severity.mjs — single source of truth, v7.0.0+)
const riskScoreValue = riskScore(counts);
const riskBandValue = riskBand(riskScoreValue);
const verdictValue = verdict(counts);
const durationMs = Date.now() - startMs;