Full port of llm-security plugin for internal use on Windows with GitHub Copilot CLI. Protocol translation layer (copilot-hook-runner.mjs) normalizes Copilot camelCase I/O to Claude Code snake_case format — all original hook scripts run unmodified. - 8 hooks with protocol translation (stdin/stdout/exit code) - 18 SKILL.md skills (Agent Skills Open Standard) - 6 .agent.md agent definitions - 20 scanners + 14 scanner lib modules (unchanged) - 14 knowledge files (unchanged) - 39 test files including copilot-port-verify.mjs (17 tests) - Windows-ready: node:path, os.tmpdir(), process.execPath, no bash Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
385 lines
12 KiB
JavaScript
385 lines
12 KiB
JavaScript
// severity.test.mjs — Tests for scanners/lib/severity.mjs
|
|
// Zero external dependencies: node:test + node:assert only.
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
SEVERITY,
|
|
riskScore,
|
|
verdict,
|
|
riskBand,
|
|
gradeFromPassRate,
|
|
OWASP_MAP,
|
|
OWASP_AGENTIC_MAP,
|
|
OWASP_SKILLS_MAP,
|
|
OWASP_MCP_MAP,
|
|
owaspCategorize,
|
|
} from '../../scanners/lib/severity.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SEVERITY
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('SEVERITY', () => {
|
|
it('exports all five severity levels', () => {
|
|
assert.ok('CRITICAL' in SEVERITY);
|
|
assert.ok('HIGH' in SEVERITY);
|
|
assert.ok('MEDIUM' in SEVERITY);
|
|
assert.ok('LOW' in SEVERITY);
|
|
assert.ok('INFO' in SEVERITY);
|
|
});
|
|
|
|
it('has lowercase string values', () => {
|
|
assert.equal(SEVERITY.CRITICAL, 'critical');
|
|
assert.equal(SEVERITY.HIGH, 'high');
|
|
assert.equal(SEVERITY.MEDIUM, 'medium');
|
|
assert.equal(SEVERITY.LOW, 'low');
|
|
assert.equal(SEVERITY.INFO, 'info');
|
|
});
|
|
|
|
it('is frozen (immutable)', () => {
|
|
assert.ok(Object.isFrozen(SEVERITY));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// riskScore
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('riskScore', () => {
|
|
it('returns 0 when all counts are zero', () => {
|
|
assert.equal(riskScore({ critical: 0, high: 0, medium: 0, low: 0, info: 0 }), 0);
|
|
});
|
|
|
|
it('returns 0 for empty counts object', () => {
|
|
assert.equal(riskScore({}), 0);
|
|
});
|
|
|
|
it('returns 25 for one critical finding (weight=25)', () => {
|
|
assert.equal(riskScore({ critical: 1 }), 25);
|
|
});
|
|
|
|
it('returns 100 (capped) for four critical findings (4*25=100)', () => {
|
|
assert.equal(riskScore({ critical: 4 }), 100);
|
|
});
|
|
|
|
it('caps at 100 even if raw score would exceed it', () => {
|
|
assert.equal(riskScore({ critical: 10, high: 10 }), 100);
|
|
});
|
|
|
|
it('returns 10 for one high finding (weight=10)', () => {
|
|
assert.equal(riskScore({ high: 1 }), 10);
|
|
});
|
|
|
|
it('returns 4 for one medium finding (weight=4)', () => {
|
|
assert.equal(riskScore({ medium: 1 }), 4);
|
|
});
|
|
|
|
it('returns 1 for one low finding (weight=1)', () => {
|
|
assert.equal(riskScore({ low: 1 }), 1);
|
|
});
|
|
|
|
it('returns 0 for info-only findings (weight=0)', () => {
|
|
assert.equal(riskScore({ info: 100 }), 0);
|
|
});
|
|
|
|
it('returns correct sum for mixed counts', () => {
|
|
// 1*25 + 2*10 + 3*4 + 4*1 + 5*0 = 25+20+12+4+0 = 61
|
|
assert.equal(riskScore({ critical: 1, high: 2, medium: 3, low: 4, info: 5 }), 61);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// verdict
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('verdict', () => {
|
|
it('returns ALLOW for zero findings', () => {
|
|
assert.equal(verdict({ critical: 0, high: 0, medium: 0, low: 0, info: 0 }), 'ALLOW');
|
|
});
|
|
|
|
it('returns ALLOW for empty counts', () => {
|
|
assert.equal(verdict({}), 'ALLOW');
|
|
});
|
|
|
|
it('returns BLOCK when critical >= 1', () => {
|
|
assert.equal(verdict({ critical: 1 }), 'BLOCK');
|
|
});
|
|
|
|
it('returns BLOCK when score >= 61 (even with no critical)', () => {
|
|
// Need score >= 61 without critical: 7 high = 70 >= 61
|
|
assert.equal(verdict({ high: 7 }), 'BLOCK');
|
|
});
|
|
|
|
it('returns BLOCK for score exactly 61', () => {
|
|
// 1 critical + 2 high + 3 medium + 4 low = 25+20+12+4 = 61
|
|
assert.equal(verdict({ critical: 1, high: 2, medium: 3, low: 4 }), 'BLOCK');
|
|
});
|
|
|
|
it('returns WARNING when high >= 1 (and no critical)', () => {
|
|
assert.equal(verdict({ high: 1 }), 'WARNING');
|
|
});
|
|
|
|
it('returns WARNING when score >= 21 (even with no high or critical)', () => {
|
|
// 6 medium = 24 >= 21; no critical or high
|
|
assert.equal(verdict({ medium: 6 }), 'WARNING');
|
|
});
|
|
|
|
it('returns WARNING for score exactly 21 (no high or critical)', () => {
|
|
// Smallest score >= 21 from low only would need 21 low, but medium is easier:
|
|
// 5 medium + 1 low = 20+1 = 21
|
|
assert.equal(verdict({ medium: 5, low: 1 }), 'WARNING');
|
|
});
|
|
|
|
it('returns ALLOW for score of 20 (low only, no high/critical)', () => {
|
|
assert.equal(verdict({ low: 20 }), 'ALLOW');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// riskBand
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('riskBand', () => {
|
|
it('returns Low for score 0', () => {
|
|
assert.equal(riskBand(0), 'Low');
|
|
});
|
|
|
|
it('returns Low for score 20 (boundary)', () => {
|
|
assert.equal(riskBand(20), 'Low');
|
|
});
|
|
|
|
it('returns Medium for score 21', () => {
|
|
assert.equal(riskBand(21), 'Medium');
|
|
});
|
|
|
|
it('returns Medium for score 25', () => {
|
|
assert.equal(riskBand(25), 'Medium');
|
|
});
|
|
|
|
it('returns Medium for score 40 (boundary)', () => {
|
|
assert.equal(riskBand(40), 'Medium');
|
|
});
|
|
|
|
it('returns High for score 41', () => {
|
|
assert.equal(riskBand(41), 'High');
|
|
});
|
|
|
|
it('returns High for score 50', () => {
|
|
assert.equal(riskBand(50), 'High');
|
|
});
|
|
|
|
it('returns High for score 60 (boundary)', () => {
|
|
assert.equal(riskBand(60), 'High');
|
|
});
|
|
|
|
it('returns Critical for score 61', () => {
|
|
assert.equal(riskBand(61), 'Critical');
|
|
});
|
|
|
|
it('returns Critical for score 75', () => {
|
|
assert.equal(riskBand(75), 'Critical');
|
|
});
|
|
|
|
it('returns Critical for score 80 (boundary)', () => {
|
|
assert.equal(riskBand(80), 'Critical');
|
|
});
|
|
|
|
it('returns Extreme for score 81', () => {
|
|
assert.equal(riskBand(81), 'Extreme');
|
|
});
|
|
|
|
it('returns Extreme for score 95', () => {
|
|
assert.equal(riskBand(95), 'Extreme');
|
|
});
|
|
|
|
it('returns Extreme for score 100', () => {
|
|
assert.equal(riskBand(100), 'Extreme');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// gradeFromPassRate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('gradeFromPassRate', () => {
|
|
it('returns A for perfect pass rate with no critical failures', () => {
|
|
assert.equal(gradeFromPassRate(1.0, 0, 0), 'A');
|
|
});
|
|
|
|
it('returns A for passRate >= 0.89 with no critical category fails and no crits', () => {
|
|
assert.equal(gradeFromPassRate(0.9, 0, 0), 'A');
|
|
});
|
|
|
|
it('does NOT return A if passRate >= 0.89 but has a critical category fail', () => {
|
|
const grade = gradeFromPassRate(0.9, 1, 0);
|
|
assert.notEqual(grade, 'A');
|
|
});
|
|
|
|
it('returns B for passRate >= 0.72 with no critical findings', () => {
|
|
assert.equal(gradeFromPassRate(0.8, 0, 0), 'B');
|
|
});
|
|
|
|
it('returns B for passRate >= 0.72 even with critical category fails (if no critical findings)', () => {
|
|
assert.equal(gradeFromPassRate(0.75, 2, 0), 'B');
|
|
});
|
|
|
|
it('returns C for passRate >= 0.56', () => {
|
|
assert.equal(gradeFromPassRate(0.6, 0, 0), 'C');
|
|
});
|
|
|
|
it('returns C for passRate = 0.56 (lower boundary)', () => {
|
|
assert.equal(gradeFromPassRate(0.56, 0, 0), 'C');
|
|
});
|
|
|
|
it('returns D for passRate >= 0.33 but < 0.56', () => {
|
|
assert.equal(gradeFromPassRate(0.45, 0, 0), 'D');
|
|
});
|
|
|
|
it('returns D for passRate = 0.33 (lower boundary)', () => {
|
|
assert.equal(gradeFromPassRate(0.33, 0, 0), 'D');
|
|
});
|
|
|
|
it('returns F for passRate < 0.33', () => {
|
|
assert.equal(gradeFromPassRate(0.2, 0, 0), 'F');
|
|
});
|
|
|
|
it('returns F for passRate = 0', () => {
|
|
assert.equal(gradeFromPassRate(0, 0, 0), 'F');
|
|
});
|
|
|
|
it('returns F when critCount >= 3 regardless of passRate', () => {
|
|
assert.equal(gradeFromPassRate(1.0, 0, 3), 'F');
|
|
assert.equal(gradeFromPassRate(0.9, 0, 5), 'F');
|
|
});
|
|
|
|
it('uses default values for optional parameters', () => {
|
|
// gradeFromPassRate(passRate) with no optional args — should not throw
|
|
const grade = gradeFromPassRate(0.95);
|
|
assert.ok(['A', 'B', 'C', 'D', 'F'].includes(grade));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OWASP Framework Maps
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('OWASP framework maps', () => {
|
|
it('OWASP_MAP includes TFA scanner prefix', () => {
|
|
assert.ok(OWASP_MAP.TFA, 'expected TFA key in OWASP_MAP');
|
|
assert.ok(OWASP_MAP.TFA.includes('LLM01'));
|
|
assert.ok(OWASP_MAP.TFA.includes('LLM02'));
|
|
assert.ok(OWASP_MAP.TFA.includes('LLM06'));
|
|
});
|
|
|
|
it('OWASP_AGENTIC_MAP has all 8 scanner prefixes', () => {
|
|
for (const prefix of ['UNI', 'ENT', 'PRM', 'DEP', 'TNT', 'GIT', 'NET', 'TFA']) {
|
|
assert.ok(OWASP_AGENTIC_MAP[prefix], `expected ${prefix} in OWASP_AGENTIC_MAP`);
|
|
assert.ok(OWASP_AGENTIC_MAP[prefix].length > 0);
|
|
for (const cat of OWASP_AGENTIC_MAP[prefix]) {
|
|
assert.ok(cat.startsWith('ASI'), `expected ASI prefix, got ${cat}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('OWASP_SKILLS_MAP has all 8 scanner prefixes', () => {
|
|
for (const prefix of ['UNI', 'ENT', 'PRM', 'DEP', 'TNT', 'GIT', 'NET', 'TFA']) {
|
|
assert.ok(OWASP_SKILLS_MAP[prefix], `expected ${prefix} in OWASP_SKILLS_MAP`);
|
|
for (const cat of OWASP_SKILLS_MAP[prefix]) {
|
|
assert.ok(cat.startsWith('AST'), `expected AST prefix, got ${cat}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('OWASP_MCP_MAP has all 8 scanner prefixes', () => {
|
|
for (const prefix of ['UNI', 'ENT', 'PRM', 'DEP', 'TNT', 'GIT', 'NET', 'TFA']) {
|
|
assert.ok(OWASP_MCP_MAP[prefix], `expected ${prefix} in OWASP_MCP_MAP`);
|
|
for (const cat of OWASP_MCP_MAP[prefix]) {
|
|
assert.ok(cat.startsWith('MCP'), `expected MCP prefix, got ${cat}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('all framework maps are frozen', () => {
|
|
assert.ok(Object.isFrozen(OWASP_MAP));
|
|
assert.ok(Object.isFrozen(OWASP_AGENTIC_MAP));
|
|
assert.ok(Object.isFrozen(OWASP_SKILLS_MAP));
|
|
assert.ok(Object.isFrozen(OWASP_MCP_MAP));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// owaspCategorize — multi-framework support
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('owaspCategorize — multi-framework', () => {
|
|
it('categorizes findings with LLM prefix', () => {
|
|
const findings = [
|
|
{ owasp: 'LLM01', severity: 'critical' },
|
|
{ owasp: 'LLM01', severity: 'high' },
|
|
{ owasp: 'LLM06', severity: 'medium' },
|
|
];
|
|
const cats = owaspCategorize(findings);
|
|
assert.equal(cats['LLM01'].count, 2);
|
|
assert.equal(cats['LLM01'].critical, 1);
|
|
assert.equal(cats['LLM01'].high, 1);
|
|
assert.equal(cats['LLM06'].count, 1);
|
|
});
|
|
|
|
it('categorizes findings with ASI prefix', () => {
|
|
const findings = [
|
|
{ owasp: 'ASI01', severity: 'critical' },
|
|
{ owasp: 'ASI02 ASI05', severity: 'high' },
|
|
];
|
|
const cats = owaspCategorize(findings);
|
|
assert.equal(cats['ASI01'].count, 1);
|
|
assert.equal(cats['ASI02'].count, 1);
|
|
assert.equal(cats['ASI05'].count, 1);
|
|
});
|
|
|
|
it('categorizes findings with AST prefix', () => {
|
|
const findings = [
|
|
{ owasp: 'AST03', severity: 'high' },
|
|
];
|
|
const cats = owaspCategorize(findings);
|
|
assert.equal(cats['AST03'].count, 1);
|
|
assert.equal(cats['AST03'].high, 1);
|
|
});
|
|
|
|
it('categorizes findings with MCP prefix', () => {
|
|
const findings = [
|
|
{ owasp: 'MCP1 MCP6', severity: 'critical' },
|
|
];
|
|
const cats = owaspCategorize(findings);
|
|
assert.equal(cats['MCP1'].count, 1);
|
|
assert.equal(cats['MCP6'].count, 1);
|
|
});
|
|
|
|
it('categorizes mixed-framework findings in same owasp field', () => {
|
|
const findings = [
|
|
{ owasp: 'LLM01 ASI01 AST01', severity: 'critical' },
|
|
];
|
|
const cats = owaspCategorize(findings);
|
|
assert.equal(cats['LLM01'].count, 1);
|
|
assert.equal(cats['ASI01'].count, 1);
|
|
assert.equal(cats['AST01'].count, 1);
|
|
});
|
|
|
|
it('falls back to TFA in OWASP_MAP for scanner prefix', () => {
|
|
const findings = [
|
|
{ scanner: 'TFA', severity: 'high' },
|
|
];
|
|
const cats = owaspCategorize(findings);
|
|
assert.ok(cats['LLM01'], 'expected LLM01 from TFA fallback');
|
|
assert.ok(cats['LLM02'], 'expected LLM02 from TFA fallback');
|
|
assert.ok(cats['LLM06'], 'expected LLM06 from TFA fallback');
|
|
});
|
|
|
|
it('returns Unmapped for findings with no owasp and unknown scanner', () => {
|
|
const findings = [
|
|
{ severity: 'low' },
|
|
];
|
|
const cats = owaspCategorize(findings);
|
|
assert.equal(cats['Unmapped'].count, 1);
|
|
});
|
|
});
|