ktg-plugin-marketplace/plugins/llm-security/scanners/lib/output.mjs

177 lines
5.3 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
* @param {string} opts.scanner - Scanner prefix (UNI, ENT, PRM, DEP, TNT, GIT, NET)
* @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,
},
};
}