llm-security/scanners/lib/output.mjs
Kjell Tore Guttormsen 0f1be986d0 docs(llm-security): v8 Phase 2 — B10 docs consistency, counts pinned by test
Extends tests/lib/doc-consistency.test.mjs with 15 cases that derive every
inventory count from source instead of trusting prose. Each count has one
stated derivation; a doc surface that disagrees now fails the suite.

Counts corrected (all were wrong before the test existed):
- orchestrated scanners: docs said 10 (README, ci-cd-guide), CLAUDE.md said 12,
  the synthesizer agent said 9 — scan-orchestrator registers 14
- total scanners: README badge + 3 prose sites said 23; the counting rule in
  docs/scanner-reference.md (14 orchestrated + 8 standalone) yields 22
- knowledge files: README badge + prose said 22; knowledge/ holds 23
- output.mjs finding() prefix JSDoc listed 10 of the 17 prefixes actually
  passed to it (missing IDE, MCI, MEM, PST, SCR, TFA, WFL)
- norwegian-context.md said "8 hooks, 10 scanners" -> 9 and 14
- ci-cd-guide "what gets scanned" table listed 10 of 14 rows; adds workflow,
  trigger abuse, signature, AST taint

Two plan items changed after verifying against ground truth:
- CLAUDE.md's synthesizer "(12 scanners)" was not a deliberate subset; the
  agent file itself claimed 9. Both bumped to 14.
- compliance-mapping.md's "13 posture categories" is substantively correct —
  its matrix has exactly 13 data rows, and categories 14-16 are governance
  consumers of the file, not rows in it. The planned 13->16 bump would have
  made the document false. Wording clarified to "code-level" instead, and the
  test now pins row count against the stated claim.

Framework currency (both verified against primary reporting):
- EU AI Act: Digital Omnibus (EP 2026-06-16, Council 2026-06-29) deferred the
  high-risk obligations behind Art. 9/15 to 2027-12-02 (Annex III) and
  2028-08-02 (Annex I); transparency still applies from 2026-08-02
- OWASP Agentic AI Top 10 labelled as the 2026 edition

Also: CLAUDE.md Distribution section rewritten monorepo -> polyrepo (each
plugin is its own repo; the catalog pins url + ref per plugin), and
current-state test counts synced 2013 -> 2034. Release-note paragraphs keep
their historical numbers.

No scanner, hook, or command behaviour changes. Suite 2034/2034.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wt4YQGoXwRja5K2Zmv8RZE
2026-08-02 21:22:20 +02:00

179 lines
5.6 KiB
JavaScript

// output.mjs — Finding and result builders, JSON envelope
// Zero dependencies (uses severity.mjs).
import { riskScore, verdict, riskBand, owaspCategorize } from './severity.mjs';
let findingCounter = 0;
/**
* Reset the global finding counter.
* Called between scanner runs in the orchestrator and before each test.
*/
export function resetCounter() {
findingCounter = 0;
}
/**
* Create a finding object.
* @param {object} opts
* Every prefix below is pinned by tests/lib/doc-consistency.test.mjs against the
* scanners that actually call this function — orchestrated first, then standalone.
* @param {string} opts.scanner - Scanner prefix (UNI, ENT, PRM, DEP, TNT, GIT, NET, MEM, SCR, WFL, TRG, SIG, AST, TFA, IDE, MCI, PST)
* @param {string} opts.severity - From SEVERITY constants
* @param {string} opts.title - Short finding title
* @param {string} opts.description - Detailed description
* @param {string} [opts.file] - Affected file path (relative)
* @param {number} [opts.line] - Line number
* @param {string} [opts.evidence] - Redacted evidence string
* @param {string} [opts.owasp] - OWASP reference (e.g. "LLM01")
* @param {string} [opts.recommendation] - Fix suggestion
* @returns {object}
*/
export function finding(opts) {
findingCounter++;
const id = `DS-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
return {
id,
scanner: opts.scanner,
severity: opts.severity,
title: opts.title,
description: opts.description,
file: opts.file || null,
line: opts.line || null,
evidence: opts.evidence || null,
owasp: opts.owasp || null,
recommendation: opts.recommendation || null,
};
}
/**
* Create a scanner result envelope.
* @param {string} scannerName
* @param {'ok'|'error'|'skipped'} status
* @param {object[]} findings
* @param {number} filesScanned
* @param {number} durationMs
* @param {string} [errorMsg]
* @returns {object}
*/
export function scannerResult(scannerName, status, findings, filesScanned, durationMs, errorMsg) {
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
for (const f of findings) {
counts[f.severity] = (counts[f.severity] || 0) + 1;
}
const result = {
scanner: scannerName,
status,
files_scanned: filesScanned,
duration_ms: durationMs,
findings,
counts,
};
if (errorMsg) result.error = errorMsg;
return result;
}
/**
* Create a fix result object for the auto-cleaner.
* @param {object} opts
* @param {string} opts.finding_id - Original finding ID (e.g. "DS-UNI-001")
* @param {string} opts.file - Affected file path (relative)
* @param {string} opts.operation - Fix operation name (e.g. "strip_zero_width")
* @param {'applied'|'skipped'|'failed'} opts.status
* @param {string} opts.description - What was done
* @param {string} [opts.error] - Error message if failed
* @returns {object}
*/
export function fixResult(opts) {
const result = {
finding_id: opts.finding_id,
file: opts.file,
operation: opts.operation,
status: opts.status,
description: opts.description,
};
if (opts.error) result.error = opts.error;
return result;
}
/**
* Build the top-level output envelope for the auto-cleaner.
* @param {string} targetPath
* @param {boolean} dryRun
* @param {object[]} fixes - Array of fixResult objects
* @param {object[]} errors - Array of error objects
* @param {number} durationMs
* @returns {object}
*/
export function cleanEnvelope(targetPath, dryRun, fixes, errors, durationMs) {
const applied = fixes.filter(f => f.status === 'applied').length;
const skipped = fixes.filter(f => f.status === 'skipped').length;
const failed = fixes.filter(f => f.status === 'failed').length;
const filesModified = new Set(fixes.filter(f => f.status === 'applied').map(f => f.file)).size;
return {
meta: {
target: targetPath,
timestamp: new Date().toISOString(),
dry_run: dryRun,
duration_ms: durationMs,
},
summary: {
findings_received: fixes.length + errors.length,
fixes_applied: applied,
fixes_skipped: skipped,
fixes_failed: failed,
files_modified: filesModified,
},
fixes,
errors,
};
}
/**
* Build the top-level output envelope from all scanner results.
* @param {string} targetPath
* @param {Record<string, object>} scannerResults - keyed by scanner short name
* @param {number} totalDurationMs
* @returns {object}
*/
export function envelope(targetPath, scannerResults, totalDurationMs) {
const aggCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
const allFindings = [];
let totalFindings = 0;
let scannersOk = 0;
let scannersError = 0;
let scannersSkipped = 0;
for (const r of Object.values(scannerResults)) {
for (const sev of Object.keys(aggCounts)) {
aggCounts[sev] += r.counts[sev] || 0;
}
totalFindings += r.findings.length;
allFindings.push(...r.findings);
if (r.status === 'ok') scannersOk++;
else if (r.status === 'error') scannersError++;
else if (r.status === 'skipped') scannersSkipped++;
}
return {
meta: {
target: targetPath,
timestamp: new Date().toISOString(),
node_version: process.version,
total_duration_ms: totalDurationMs,
},
scanners: scannerResults,
aggregate: {
total_findings: totalFindings,
counts: aggCounts,
risk_score: riskScore(aggCounts),
risk_band: riskBand(riskScore(aggCounts)),
verdict: verdict(aggCounts),
owasp_breakdown: owaspCategorize(allFindings),
scanners_ok: scannersOk,
scanners_error: scannersError,
scanners_skipped: scannersSkipped,
},
};
}