// 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', }); const SEVERITY_WEIGHTS = { critical: 25, high: 10, medium: 4, low: 1, info: 0 }; /** * Calculate aggregate risk score from severity counts. * @param {{ critical: number, high: number, medium: number, low: number, info: number }} counts * @returns {number} 0-100 capped score */ export function riskScore(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; return Math.min(raw, 100); } /** * Derive verdict from severity counts and risk score. * BLOCK if Critical >= 1 OR score >= 61. WARNING if High >= 1 OR score >= 21. Otherwise ALLOW. * @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 >= 61) return 'BLOCK'; if ((counts.high || 0) >= 1 || score >= 21) return 'WARNING'; return 'ALLOW'; } /** * Map a 0-100 risk score to a human-readable risk band. * @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'; 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'], }); /** * 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'], }); /** * 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'], }); /** * 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'], }); /** * 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} */ 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; }