From d83424a782d3cae038bab1ec915b872ec0e6bfdc Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 19 Apr 2026 22:00:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(llm-security)!:=20v7.0.0=20commit=201=20?= =?UTF-8?q?=E2=80=94=20severity-dominated=20log-scaled=20risk=20score?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../llm-security/scanners/lib/severity.mjs | 67 +++++++++++++++---- .../llm-security/scanners/posture-scanner.mjs | 21 ++---- 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/plugins/llm-security/scanners/lib/severity.mjs b/plugins/llm-security/scanners/lib/severity.mjs index 128bd82..892dff4 100644 --- a/plugins/llm-security/scanners/lib/severity.mjs +++ b/plugins/llm-security/scanners/lib/severity.mjs @@ -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'; } diff --git a/plugins/llm-security/scanners/posture-scanner.mjs b/plugins/llm-security/scanners/posture-scanner.mjs index 9e6c2c8..e53c76b 100644 --- a/plugins/llm-security/scanners/posture-scanner.mjs +++ b/plugins/llm-security/scanners/posture-scanner.mjs @@ -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;