Closes Phase 3 (B11) of the v8.0.0 plan. Three parts, all with the failing
test written first.
riskScoreV1 removed. scanners/lib/severity.mjs drops riskScoreV1() and its
SEVERITY_WEIGHTS_V1 table - @deprecated since v7.0.0, kept for diff/comparison,
zero callers in code or tests (re-verified, not taken from the plan). The v1
weights are recorded in CHANGELOG so an old score stays re-derivable. riskScore
(v2) is untouched; a test pins that one critical still lands in the 70-95 tier
and that 50 lows score below it, which is exactly the case v1 collapsed to 100.
Posture category 12 no longer keys off an identifier name. The check was
/TRIFECTA_MODE/i over the session-guard source, which measured what a constant
was CALLED rather than whether enforcement was configurable. With the env-var
gone, that regex would have dropped every correctly-migrated project from PASS
to PARTIAL - the gate punishing the migration it exists to encourage. It now
matches getPolicyValue('trifecta', 'mode', ...) and still accepts a pre-v8
vendored guard reading the old env-var, because a third-party project carries
its own hook copy and is equally configurable either way; the evidence line
says which of the two was found. The PARTIAL finding recommended setting an
env-var that v8 ignores; it now names the policy key. The grade-a fixture hook
moves to the policy-era form.
Two never-implemented env-vars deleted from the docs. LLM_SECURITY_SCR_OFFLINE
(ci-cd-guide) and LLM_SECURITY_OFFLINE (supply-chain-attack example) were
documented as OSV.dev / npm-audit kill-switches. No code has ever read either -
verified by grep across scanners, hooks and scripts, which finds them only in
markdown. A promised kill-switch that does nothing is worse than a documented
absence: it is trusted precisely when the run is meant to be air-gapped. The
docs now say there is none and that egress must be blocked at the network
layer. The LLM_SECURITY_AUDIT_* wildcard is narrowed to the one real key.
Docs. Migration section in README + CHANGELOG with the env-var -> policy-key
table, the detection commands (env + shell rc + .envrc + workflows), and the
explicit warning that a removed variable is now INERT rather than an error -
which is the failure mode that loses a project its configuration silently. The
hardening-guide env table splits into surviving vars and a removed-vars
migration table; its "promote to block" runbook named two variables that no
longer exist. Also swept: CLAUDE.md hook table, scanner-reference, ci-cd-guide,
both lethal-trifecta example docs, mitigation-matrix, injection-research.
Test counts in README/CLAUDE.md synced 2034 -> 2045.
Suite 2045 tests, 0 fail (2039 + 4 posture-trifecta + 2 riskScoreV1). The two
known parallel-load flakes did not recur this run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
231 lines
7.2 KiB
JavaScript
231 lines
7.2 KiB
JavaScript
// severity.mjs — Constants, risk score calculation, verdict logic
|
|
// Zero dependencies. Used by all scanners and the orchestrator.
|
|
|
|
export const SEVERITY = Object.freeze({
|
|
CRITICAL: 'critical',
|
|
HIGH: 'high',
|
|
MEDIUM: 'medium',
|
|
LOW: 'low',
|
|
INFO: 'info',
|
|
});
|
|
|
|
/**
|
|
* 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=93, 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
|
|
*
|
|
* Info severity (B3, v7.2.0):
|
|
* `info` counts are scoring-inert — accepted in the input shape but
|
|
* ignored by this formula. They contribute 0 to risk_score, do not
|
|
* affect verdict (BLOCK/WARNING/ALLOW), and do not affect riskBand
|
|
* (Low/Medium/High/Critical/Extreme). They ARE surfaced in
|
|
* `owaspCategorize` aggregates and in scanner report bodies for
|
|
* observability. Operators reading a report with N info findings
|
|
* should treat them as informational telemetry, not as input to
|
|
* the verdict.
|
|
*
|
|
* @param {{ critical: number, high: number, medium: number, low: number, info?: number }} counts
|
|
* `info` is accepted for shape completeness but ignored — see above.
|
|
* @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));
|
|
}
|
|
|
|
/**
|
|
* Derive verdict from severity counts and risk score (v7.0.0 thresholds).
|
|
* Aligned to v2 riskBand cutoffs so verdict and band are co-monotonic:
|
|
* BLOCK if critical >= 1 OR score >= 65 (Critical/Extreme band)
|
|
* WARNING if high >= 1 OR score >= 15 (Medium/High band)
|
|
* ALLOW otherwise (Low band)
|
|
* @param {{ critical: number, high: number, medium: number, low: number, info: number }} counts
|
|
* @returns {'BLOCK' | 'WARNING' | 'ALLOW'}
|
|
*/
|
|
export function verdict(counts) {
|
|
const score = riskScore(counts);
|
|
if ((counts.critical || 0) >= 1 || score >= 65) return 'BLOCK';
|
|
if ((counts.high || 0) >= 1 || score >= 15) return 'WARNING';
|
|
return 'ALLOW';
|
|
}
|
|
|
|
/**
|
|
* 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 <= 14) return 'Low';
|
|
if (score <= 39) return 'Medium';
|
|
if (score <= 64) return 'High';
|
|
if (score <= 84) return 'Critical';
|
|
return 'Extreme';
|
|
}
|
|
|
|
/**
|
|
* Calculate A-F grade from posture/audit pass rate.
|
|
* @param {number} passRate - 0.0 to 1.0
|
|
* @param {number} failsInCritCats - Number of FAIL results in critical categories (1, 2, 5)
|
|
* @param {number} critCount - Number of Critical-severity findings
|
|
* @returns {'A' | 'B' | 'C' | 'D' | 'F'}
|
|
*/
|
|
export function gradeFromPassRate(passRate, failsInCritCats = 0, critCount = 0) {
|
|
if (passRate < 0.33 || critCount >= 3) return 'F';
|
|
if (passRate >= 0.89 && failsInCritCats === 0 && critCount === 0) return 'A';
|
|
if (passRate >= 0.72 && critCount === 0) return 'B';
|
|
if (passRate >= 0.56) return 'C';
|
|
if (passRate >= 0.33) return 'D';
|
|
return 'F';
|
|
}
|
|
|
|
/**
|
|
* Scanner prefix to OWASP LLM Top 10 category mapping.
|
|
*/
|
|
export const OWASP_MAP = Object.freeze({
|
|
UNI: ['LLM01'],
|
|
ENT: ['LLM01', 'LLM03'],
|
|
PRM: ['LLM06'],
|
|
DEP: ['LLM03'],
|
|
TNT: ['LLM01', 'LLM02'],
|
|
GIT: ['LLM03'],
|
|
NET: ['LLM02', 'LLM03'],
|
|
TFA: ['LLM01', 'LLM02', 'LLM06'],
|
|
MCI: ['LLM01', 'LLM02'],
|
|
MEM: ['LLM01'],
|
|
SCR: ['LLM03'],
|
|
PST: ['LLM01', 'LLM06'],
|
|
WFL: ['LLM02', 'LLM06'],
|
|
TRG: ['LLM06'],
|
|
SIG: ['LLM03', 'LLM02'],
|
|
AST: ['LLM01', 'LLM02'],
|
|
});
|
|
|
|
/**
|
|
* Scanner prefix to OWASP Agentic AI Top 10 (ASI) category mapping.
|
|
*/
|
|
export const OWASP_AGENTIC_MAP = Object.freeze({
|
|
UNI: ['ASI01'],
|
|
ENT: ['ASI01', 'ASI04'],
|
|
PRM: ['ASI02', 'ASI03'],
|
|
DEP: ['ASI04'],
|
|
TNT: ['ASI01', 'ASI05'],
|
|
GIT: ['ASI04'],
|
|
NET: ['ASI02', 'ASI05'],
|
|
TFA: ['ASI01', 'ASI02', 'ASI05'],
|
|
MCI: ['ASI01', 'ASI04'],
|
|
MEM: ['ASI01', 'ASI02'],
|
|
SCR: ['ASI04'],
|
|
PST: ['ASI02', 'ASI03', 'ASI04', 'ASI05'],
|
|
WFL: ['ASI04'],
|
|
TRG: [],
|
|
SIG: ['ASI04'],
|
|
AST: [],
|
|
});
|
|
|
|
/**
|
|
* Scanner prefix to OWASP Skills Top 10 (AST) category mapping.
|
|
*/
|
|
export const OWASP_SKILLS_MAP = Object.freeze({
|
|
UNI: ['AST05'],
|
|
ENT: ['AST02', 'AST05'],
|
|
PRM: ['AST03'],
|
|
DEP: ['AST06'],
|
|
TNT: ['AST01', 'AST02'],
|
|
GIT: ['AST06'],
|
|
NET: ['AST02'],
|
|
TFA: ['AST01', 'AST02', 'AST03'],
|
|
MCI: ['AST01', 'AST02'],
|
|
MEM: ['AST01', 'AST05'],
|
|
SCR: ['AST06'],
|
|
PST: ['AST01', 'AST03'],
|
|
WFL: [],
|
|
TRG: ['AST04'],
|
|
SIG: [],
|
|
AST: ['AST02'],
|
|
});
|
|
|
|
/**
|
|
* Scanner prefix to OWASP MCP Top 10 category mapping.
|
|
*/
|
|
export const OWASP_MCP_MAP = Object.freeze({
|
|
UNI: ['MCP06'],
|
|
ENT: ['MCP01', 'MCP06'],
|
|
PRM: ['MCP02', 'MCP07'],
|
|
DEP: ['MCP04'],
|
|
TNT: ['MCP05', 'MCP06'],
|
|
GIT: ['MCP04'],
|
|
NET: ['MCP02', 'MCP10'],
|
|
TFA: ['MCP03', 'MCP06'],
|
|
MCI: ['MCP03', 'MCP06', 'MCP09'],
|
|
MEM: ['MCP05', 'MCP06'],
|
|
SCR: ['MCP04'],
|
|
PST: ['MCP02', 'MCP07'],
|
|
WFL: [],
|
|
TRG: [],
|
|
SIG: [],
|
|
AST: [],
|
|
});
|
|
|
|
/**
|
|
* Regex matching all supported OWASP framework prefixes:
|
|
* LLM01-LLM10, ASI01-ASI10, AST01-AST10, MCP01-MCP10 (MCP1-MCP10 also accepted).
|
|
*/
|
|
const OWASP_PREFIX_RE = /(?:LLM|ASI|AST|MCP)\d{1,2}/g;
|
|
|
|
/**
|
|
* Group findings by OWASP category across all frameworks.
|
|
* Uses each finding's `owasp` field if present, otherwise falls back to OWASP_MAP by scanner prefix.
|
|
* Recognizes LLM, ASI, AST, and MCP prefixes.
|
|
* @param {object[]} findings - Array of finding objects with scanner, owasp, and severity fields
|
|
* @returns {Record<string, { count: number, critical: number, high: number, medium: number, low: number, info: number }>}
|
|
*/
|
|
export function owaspCategorize(findings) {
|
|
const cats = {};
|
|
for (const f of findings) {
|
|
const categories = [];
|
|
if (f.owasp) {
|
|
const match = f.owasp.match(OWASP_PREFIX_RE);
|
|
if (match) categories.push(...match);
|
|
}
|
|
if (categories.length === 0 && f.scanner && OWASP_MAP[f.scanner]) {
|
|
categories.push(...OWASP_MAP[f.scanner]);
|
|
}
|
|
if (categories.length === 0) categories.push('Unmapped');
|
|
|
|
for (const cat of categories) {
|
|
if (!cats[cat]) cats[cat] = { count: 0, critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
cats[cat].count++;
|
|
if (f.severity && cats[cat][f.severity] !== undefined) {
|
|
cats[cat][f.severity]++;
|
|
}
|
|
}
|
|
}
|
|
return cats;
|
|
}
|