// 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} 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, }, }; }