177 lines
5.3 KiB
JavaScript
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,
|
|
},
|
|
};
|
|
}
|