ktg-plugin-marketplace/plugins/llm-security/tests/lib/output.test.mjs
Kjell Tore Guttormsen 6f86de937a feat(llm-security)!: v7.0.0 commit 6 — tests, docs, version bump
Final commit in the trustworthy-scoring series. Bundles verdict cutoff
alignment, the last suite of tests, and all documentation touch-points
that quote version numbers or describe v7.0.0 behaviour.

Verdict/band co-monotonicity
- `scanners/lib/severity.mjs` — verdict cutoffs moved from 61/21 to 65/15
  so `BLOCK >= 65`, `WARNING >= 15` locks onto the v2 riskBand() boundaries.
  Prevents "BLOCK / Medium band" contradictions under the v2 formula.

Scanner hardening (bug fixes from v7.0.0 testing)
- `scanners/entropy-scanner.mjs` — `policy_source` now uses
  `existsSync('.llm-security/policy.json')` instead of value-based check.
  Old heuristic always reported 'policy.json' because DEFAULT_POLICY now
  carries an `entropy.thresholds` section.
- `scanners/lib/file-discovery.mjs` — `.sass` and GPU shader extensions
  (`.glsl, .frag, .vert, .shader, .wgsl`) added to TEXT_EXTENSIONS. Without
  this, shader files were invisible to file-discovery, so they were never
  counted as skipped by the entropy-scanner extension filter.

Tests
- `tests/scanners/entropy-context.test.mjs` (new, 24 tests) — A. File-ext
  skip (4), B. Line-level rules 11-17 (8), C. Policy overrides (3).
  Fixtures generate 80-char base64 payloads at runtime via
  `crypto.randomBytes` to dodge the plugin's own pre-edit credential hook
  on the test source.
- `tests/lib/severity.test.mjs` — rewritten with v2 scoring table (70
  tests total, was 52).
- `tests/lib/output.test.mjs:243` — "1 critical = score 80" under v2
  (was 25 under v1).
- Full suite: 1485/1485 green (was 1461).

Docs
- `CHANGELOG.md` — v7.0.0 entry with BREAKING CHANGES section.
- `README.md` (plugin + marketplace root) — version badge, history table,
  plugin-card version string, test count.
- `CLAUDE.md` — header version, "v7.0.0 — Trustworthy scoring" summary
  paragraph at the top.
- `docs/security-hardening-guide.md` — new section 6 "Calibration & false
  positives" documenting v2 formula, context-aware entropy scanner,
  typosquat allowlist, and §6.4 tuning workflow. Existing "Recommended
  baseline" section renumbered to §7.

Version bump
- `6.6.0 -> 7.0.0` across package.json, .claude-plugin/plugin.json,
  scanners/ide-extension-scanner.mjs VERSION const, README badge,
  CLAUDE.md header, marketplace root README card.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:26:35 +02:00

278 lines
10 KiB
JavaScript

// output.test.mjs — Tests for scanners/lib/output.mjs
// Zero external dependencies: node:test + node:assert only.
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
resetCounter,
finding,
scannerResult,
envelope,
} from '../../scanners/lib/output.mjs';
// ---------------------------------------------------------------------------
// finding + resetCounter
// ---------------------------------------------------------------------------
describe('finding', () => {
beforeEach(() => {
resetCounter();
});
it('returns an object with auto-incrementing ID in DS-SCANNER-NNN format', () => {
const f = finding({ scanner: 'UNI', severity: 'high', title: 'Test', description: 'Desc' });
assert.equal(f.id, 'DS-UNI-001');
});
it('increments ID with each call', () => {
const f1 = finding({ scanner: 'UNI', severity: 'high', title: 'A', description: 'Desc' });
const f2 = finding({ scanner: 'ENT', severity: 'medium', title: 'B', description: 'Desc' });
const f3 = finding({ scanner: 'PRM', severity: 'low', title: 'C', description: 'Desc' });
assert.equal(f1.id, 'DS-UNI-001');
assert.equal(f2.id, 'DS-ENT-002');
assert.equal(f3.id, 'DS-PRM-003');
});
it('zero-pads counter to 3 digits', () => {
for (let i = 0; i < 9; i++) {
finding({ scanner: 'UNI', severity: 'info', title: `F${i}`, description: 'x' });
}
const f10 = finding({ scanner: 'UNI', severity: 'info', title: 'F10', description: 'x' });
assert.equal(f10.id, 'DS-UNI-010');
});
it('includes all required fields', () => {
const f = finding({
scanner: 'ENT',
severity: 'critical',
title: 'High Entropy Secret',
description: 'Found a high-entropy string that looks like an API key.',
});
assert.equal(f.scanner, 'ENT');
assert.equal(f.severity, 'critical');
assert.equal(f.title, 'High Entropy Secret');
assert.equal(f.description, 'Found a high-entropy string that looks like an API key.');
});
it('sets optional fields to null when not provided', () => {
const f = finding({ scanner: 'UNI', severity: 'low', title: 'T', description: 'D' });
assert.equal(f.file, null);
assert.equal(f.line, null);
assert.equal(f.evidence, null);
assert.equal(f.owasp, null);
assert.equal(f.recommendation, null);
});
it('includes all provided optional fields', () => {
const f = finding({
scanner: 'TNT',
severity: 'high',
title: 'Taint flow',
description: 'Untrusted data flows into eval().',
file: 'src/runner.mjs',
line: 42,
evidence: 'eval(userInput)',
owasp: 'LLM01',
recommendation: 'Sanitize user input before evaluation.',
});
assert.equal(f.file, 'src/runner.mjs');
assert.equal(f.line, 42);
assert.equal(f.evidence, 'eval(userInput)');
assert.equal(f.owasp, 'LLM01');
assert.equal(f.recommendation, 'Sanitize user input before evaluation.');
});
});
describe('resetCounter', () => {
it('resets the counter so the next finding starts at 001', () => {
// Advance counter to some arbitrary position
finding({ scanner: 'UNI', severity: 'info', title: 'A', description: 'x' });
finding({ scanner: 'UNI', severity: 'info', title: 'B', description: 'x' });
finding({ scanner: 'UNI', severity: 'info', title: 'C', description: 'x' });
resetCounter();
const f = finding({ scanner: 'ENT', severity: 'low', title: 'After reset', description: 'x' });
assert.equal(f.id, 'DS-ENT-001');
});
it('can be called multiple times without error', () => {
assert.doesNotThrow(() => {
resetCounter();
resetCounter();
resetCounter();
});
});
});
// ---------------------------------------------------------------------------
// scannerResult
// ---------------------------------------------------------------------------
describe('scannerResult', () => {
beforeEach(() => {
resetCounter();
});
it('returns an object with the expected top-level keys', () => {
const result = scannerResult('unicode-scanner', 'ok', [], 10, 42);
assert.ok('scanner' in result);
assert.ok('status' in result);
assert.ok('findings' in result);
assert.ok('counts' in result);
assert.ok('files_scanned' in result);
assert.ok('duration_ms' in result);
});
it('sets scanner name and status correctly', () => {
const result = scannerResult('entropy-scanner', 'ok', [], 5, 100);
assert.equal(result.scanner, 'entropy-scanner');
assert.equal(result.status, 'ok');
});
it('returns empty counts for no findings', () => {
const result = scannerResult('dep-auditor', 'ok', [], 0, 0);
assert.deepEqual(result.counts, { critical: 0, high: 0, medium: 0, low: 0, info: 0 });
});
it('counts findings by severity correctly', () => {
const f1 = finding({ scanner: 'ENT', severity: 'critical', title: 'A', description: 'x' });
const f2 = finding({ scanner: 'ENT', severity: 'high', title: 'B', description: 'x' });
const f3 = finding({ scanner: 'ENT', severity: 'high', title: 'C', description: 'x' });
const f4 = finding({ scanner: 'ENT', severity: 'medium', title: 'D', description: 'x' });
const result = scannerResult('entropy-scanner', 'ok', [f1, f2, f3, f4], 20, 300);
assert.equal(result.counts.critical, 1);
assert.equal(result.counts.high, 2);
assert.equal(result.counts.medium, 1);
assert.equal(result.counts.low, 0);
assert.equal(result.counts.info, 0);
});
it('stores findings array as provided', () => {
const f = finding({ scanner: 'UNI', severity: 'low', title: 'X', description: 'y' });
const result = scannerResult('unicode-scanner', 'ok', [f], 1, 10);
assert.equal(result.findings.length, 1);
assert.equal(result.findings[0].id, f.id);
});
it('sets files_scanned and duration_ms', () => {
const result = scannerResult('git-forensics', 'ok', [], 77, 1234);
assert.equal(result.files_scanned, 77);
assert.equal(result.duration_ms, 1234);
});
it('does not include error field when errorMsg is not provided', () => {
const result = scannerResult('taint-tracer', 'ok', [], 5, 50);
assert.ok(!('error' in result));
});
it('includes error field when errorMsg is provided', () => {
const result = scannerResult('dep-auditor', 'error', [], 0, 10, 'ENOENT: package.json not found');
assert.equal(result.error, 'ENOENT: package.json not found');
assert.equal(result.status, 'error');
});
it('handles skipped status', () => {
const result = scannerResult('network-mapper', 'skipped', [], 0, 0);
assert.equal(result.status, 'skipped');
});
});
// ---------------------------------------------------------------------------
// envelope
// ---------------------------------------------------------------------------
describe('envelope', () => {
beforeEach(() => {
resetCounter();
});
it('returns an object with meta, scanners, and aggregate keys', () => {
const result = envelope('/some/path', {}, 100);
assert.ok('meta' in result);
assert.ok('scanners' in result);
assert.ok('aggregate' in result);
});
it('meta contains target, timestamp, node_version, total_duration_ms', () => {
const result = envelope('/my/project', {}, 999);
assert.equal(result.meta.target, '/my/project');
assert.ok(typeof result.meta.timestamp === 'string');
assert.ok(result.meta.timestamp.length > 0);
assert.ok(typeof result.meta.node_version === 'string');
assert.equal(result.meta.total_duration_ms, 999);
});
it('aggregate contains risk_score and verdict', () => {
const result = envelope('/project', {}, 0);
assert.ok('risk_score' in result.aggregate);
assert.ok('verdict' in result.aggregate);
});
it('aggregate has zero counts and ALLOW verdict for empty scanner results', () => {
const result = envelope('/project', {}, 0);
assert.equal(result.aggregate.total_findings, 0);
assert.equal(result.aggregate.risk_score, 0);
assert.equal(result.aggregate.verdict, 'ALLOW');
assert.deepEqual(result.aggregate.counts, { critical: 0, high: 0, medium: 0, low: 0, info: 0 });
});
it('aggregates counts from multiple scanner results', () => {
const f1 = finding({ scanner: 'UNI', severity: 'critical', title: 'A', description: 'x' });
const f2 = finding({ scanner: 'ENT', severity: 'high', title: 'B', description: 'x' });
const scanners = {
unicode: scannerResult('unicode-scanner', 'ok', [f1], 10, 50),
entropy: scannerResult('entropy-scanner', 'ok', [f2], 10, 75),
};
const result = envelope('/project', scanners, 125);
assert.equal(result.aggregate.total_findings, 2);
assert.equal(result.aggregate.counts.critical, 1);
assert.equal(result.aggregate.counts.high, 1);
});
it('computes correct risk_score from aggregated counts', () => {
// v2 formula (v7.0.0+): 1 critical = score 80 (70 + log2(2)*10 = 80)
const f = finding({ scanner: 'ENT', severity: 'critical', title: 'C', description: 'x' });
const scanners = {
entropy: scannerResult('entropy-scanner', 'ok', [f], 5, 30),
};
const result = envelope('/project', scanners, 30);
assert.equal(result.aggregate.risk_score, 80);
});
it('returns BLOCK verdict when critical finding present', () => {
const f = finding({ scanner: 'UNI', severity: 'critical', title: 'Critical', description: 'x' });
const scanners = {
uni: scannerResult('unicode-scanner', 'ok', [f], 1, 10),
};
const result = envelope('/project', scanners, 10);
assert.equal(result.aggregate.verdict, 'BLOCK');
});
it('tracks scanner ok/error/skipped counts', () => {
const scanners = {
uni: scannerResult('unicode-scanner', 'ok', [], 5, 10),
ent: scannerResult('entropy-scanner', 'error', [], 0, 5, 'failed'),
net: scannerResult('network-mapper', 'skipped', [], 0, 0),
};
const result = envelope('/project', scanners, 15);
assert.equal(result.aggregate.scanners_ok, 1);
assert.equal(result.aggregate.scanners_error, 1);
assert.equal(result.aggregate.scanners_skipped, 1);
});
it('includes owasp_breakdown in aggregate', () => {
const result = envelope('/project', {}, 0);
assert.ok('owasp_breakdown' in result.aggregate);
});
it('passes through scanner results as-is in scanners field', () => {
const sr = scannerResult('unicode-scanner', 'ok', [], 3, 20);
const scanners = { uni: sr };
const result = envelope('/project', scanners, 20);
assert.deepEqual(result.scanners, scanners);
});
});