/** * Finding and result builders for config-audit scanners. * Finding IDs: CA-{SCANNER}-{NNN} (e.g. CA-CML-001) * Zero external dependencies. */ import { riskScore, riskBand, verdict } from './severity.mjs'; let findingCounter = 0; /** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */ export function resetCounter() { findingCounter = 0; } /** * Create a finding object with auto-incremented ID. * @param {object} opts * @param {string} opts.scanner - 3-letter scanner prefix (CML, SET, HKV, RUL, etc.) * @param {string} opts.severity - critical | high | medium | low | info * @param {string} opts.title * @param {string} opts.description * @param {string} [opts.file] - file path where finding was detected * @param {number} [opts.line] - line number * @param {string} [opts.evidence] - relevant snippet * @param {string} [opts.category] - quality category * @param {string} [opts.recommendation] - suggested fix * @param {boolean} [opts.autoFixable] - can be auto-fixed * @returns {object} */ export function finding(opts) { findingCounter++; const id = `CA-${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, category: opts.category || null, recommendation: opts.recommendation || null, autoFixable: opts.autoFixable || false, }; } /** * Create a scanner result envelope. * @param {string} scannerName - 3-letter prefix * @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) { if (counts[f.severity] !== undefined) { counts[f.severity]++; } } const result = { scanner: scannerName, status, files_scanned: filesScanned, duration_ms: durationMs, findings, counts, }; if (errorMsg) result.error = errorMsg; return result; } /** * Create the top-level output envelope combining all scanner results. * @param {string} targetPath * @param {object[]} scannerResults * @param {number} totalDurationMs * @returns {object} */ export function envelope(targetPath, scannerResults, totalDurationMs) { const aggregate = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; let totalFindings = 0; let scannersOk = 0; let scannersError = 0; let scannersSkipped = 0; for (const r of scannerResults) { for (const sev of Object.keys(aggregate)) { aggregate[sev] += (r.counts[sev] || 0); } totalFindings += r.findings.length; 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(), version: '2.2.0', tool: 'config-audit', }, scanners: scannerResults, aggregate: { total_findings: totalFindings, counts: aggregate, risk_score: riskScore(aggregate), risk_band: riskBand(riskScore(aggregate)), verdict: verdict(aggregate), scanners_ok: scannersOk, scanners_error: scannersError, scanners_skipped: scannersSkipped, }, }; }