/** * Diff engine for config-audit. * Compares two scanner envelopes (baseline vs current) to detect drift. * Zero external dependencies. */ import { scoreByArea } from './scoring.mjs'; import { gradeFromPassRate } from './severity.mjs'; /** * Diff two scanner envelopes. * @param {object} baseline - Full envelope from scan-orchestrator * @param {object} current - Full envelope from scan-orchestrator * @returns {object} Diff result with new, resolved, unchanged, moved findings + score changes */ export function diffEnvelopes(baseline, current) { const baseFindings = extractFindings(baseline); const currFindings = extractFindings(current); // Build lookup maps keyed by scanner+title+file const baseByKey = groupByKey(baseFindings); const currByKey = groupByKey(currFindings); // Also build maps by scanner+title (ignoring file) for moved detection const baseByScannerTitle = groupByScannerTitle(baseFindings); const currByScannerTitle = groupByScannerTitle(currFindings); const newFindings = []; const resolvedFindings = []; const unchangedFindings = []; const movedFindings = []; const matchedBaseKeys = new Set(); const matchedCurrKeys = new Set(); // Pass 1: exact matches (scanner+title+file) for (const [key, currList] of currByKey.entries()) { const baseList = baseByKey.get(key); if (baseList && baseList.length > 0) { // Match as many as possible const matchCount = Math.min(baseList.length, currList.length); for (let i = 0; i < matchCount; i++) { unchangedFindings.push(currList[i]); } // Extra in current = new for (let i = matchCount; i < currList.length; i++) { newFindings.push(currList[i]); } matchedBaseKeys.add(key); matchedCurrKeys.add(key); } } // Pass 2: find moved findings (same scanner+title, different file) const resolvedCandidates = []; const newCandidates = []; for (const [key, baseList] of baseByKey.entries()) { if (!matchedBaseKeys.has(key)) { resolvedCandidates.push(...baseList); } else { // Any extras in baseline beyond matched count const currList = currByKey.get(key) || []; const matchCount = Math.min(baseList.length, currList.length); for (let i = matchCount; i < baseList.length; i++) { resolvedCandidates.push(baseList[i]); } } } for (const [key, currList] of currByKey.entries()) { if (!matchedCurrKeys.has(key)) { newCandidates.push(...currList); } } // Try to pair resolved candidates with new candidates as "moved" const usedResolved = new Set(); const usedNew = new Set(); for (let i = 0; i < newCandidates.length; i++) { const curr = newCandidates[i]; for (let j = 0; j < resolvedCandidates.length; j++) { if (usedResolved.has(j)) continue; const base = resolvedCandidates[j]; if (base.scanner === curr.scanner && base.title === curr.title && base.file !== curr.file) { movedFindings.push({ from: base, to: curr }); usedResolved.add(j); usedNew.add(i); break; } } } // Remaining unmatched for (let i = 0; i < resolvedCandidates.length; i++) { if (!usedResolved.has(i)) resolvedFindings.push(resolvedCandidates[i]); } for (let i = 0; i < newCandidates.length; i++) { if (!usedNew.has(i)) newFindings.push(newCandidates[i]); } // Score changes const baseAreas = scoreByArea(baseline.scanners || []); const currAreas = scoreByArea(current.scanners || []); const baseAvg = avgScore(baseAreas.areas); const currAvg = avgScore(currAreas.areas); const scoreChange = { before: { score: baseAvg, grade: gradeFromPassRate(baseAvg) }, after: { score: currAvg, grade: gradeFromPassRate(currAvg) }, delta: currAvg - baseAvg, }; // Per-area changes const areaChanges = buildAreaChanges(baseAreas.areas, currAreas.areas); // Summary const totalBefore = baseFindings.length; const totalAfter = currFindings.length; const newCount = newFindings.length; const resolvedCount = resolvedFindings.length; let trend = 'stable'; if (resolvedCount > newCount) trend = 'improving'; else if (newCount > resolvedCount) trend = 'degrading'; return { newFindings, resolvedFindings, unchangedFindings, movedFindings, scoreChange, areaChanges, summary: { totalBefore, totalAfter, newCount, resolvedCount, trend, }, }; } /** * Format a diff result into a human-readable terminal report. * @param {object} diff - Output from diffEnvelopes() * @returns {string} */ export function formatDiffReport(diff) { const lines = []; lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); lines.push(' Config-Audit Drift Report'); lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); lines.push(''); // Trend const trendIcon = diff.summary.trend === 'improving' ? '↑' : diff.summary.trend === 'degrading' ? '↓' : '→'; const trendLabel = diff.summary.trend.charAt(0).toUpperCase() + diff.summary.trend.slice(1); lines.push(` Trend: ${trendIcon} ${trendLabel}`); lines.push(''); // Score const sc = diff.scoreChange; const deltaSign = sc.delta > 0 ? '+' : ''; lines.push(` Score: ${sc.before.grade} (${sc.before.score}) → ${sc.after.grade} (${sc.after.score}) ${trendIcon} ${deltaSign}${sc.delta} points`); lines.push(''); // New findings if (diff.newFindings.length > 0) { lines.push(` New findings (${diff.newFindings.length}):`); for (const f of diff.newFindings) { const fileInfo = f.file ? ` (${f.file})` : ''; lines.push(` - [${f.severity}] ${f.title}${fileInfo}`); } lines.push(''); } // Resolved if (diff.resolvedFindings.length > 0) { lines.push(` Resolved (${diff.resolvedFindings.length}):`); for (const f of diff.resolvedFindings) { lines.push(` - [${f.severity}] ${f.title}`); } lines.push(''); } // Moved if (diff.movedFindings.length > 0) { lines.push(` Moved (${diff.movedFindings.length}):`); for (const m of diff.movedFindings) { lines.push(` - [${m.from.severity}] ${m.from.title}: ${m.from.file} → ${m.to.file}`); } lines.push(''); } // Area changes (only show areas with delta != 0) const changedAreas = diff.areaChanges.filter(a => a.delta !== 0); if (changedAreas.length > 0) { lines.push(' Area changes:'); for (const a of changedAreas) { const sign = a.delta > 0 ? '↑' : '↓'; const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`; const padding = '.'.repeat(Math.max(1, 20 - a.name.length)); lines.push(` ${a.name} ${padding} ${a.before.grade} (${a.before.score}) → ${a.after.grade} (${a.after.score}) ${sign} ${deltaStr}`); } lines.push(''); } // Unchanged summary if (diff.unchangedFindings.length > 0) { lines.push(` Unchanged: ${diff.unchangedFindings.length} finding(s)`); lines.push(''); } lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); return lines.join('\n'); } // --- Internal helpers --- function extractFindings(envelope) { const findings = []; for (const scanner of (envelope.scanners || [])) { for (const f of (scanner.findings || [])) { findings.push(f); } } return findings; } function findingKey(f) { return `${f.scanner}::${f.title}::${f.file || ''}`; } function scannerTitleKey(f) { return `${f.scanner}::${f.title}`; } function groupByKey(findings) { const map = new Map(); for (const f of findings) { const key = findingKey(f); if (!map.has(key)) map.set(key, []); map.get(key).push(f); } return map; } function groupByScannerTitle(findings) { const map = new Map(); for (const f of findings) { const key = scannerTitleKey(f); if (!map.has(key)) map.set(key, []); map.get(key).push(f); } return map; } function avgScore(areas) { if (areas.length === 0) return 0; return Math.round(areas.reduce((s, a) => s + a.score, 0) / areas.length); } function buildAreaChanges(baseAreas, currAreas) { const baseMap = new Map(baseAreas.map(a => [a.name, a])); const currMap = new Map(currAreas.map(a => [a.name, a])); const allNames = new Set([...baseMap.keys(), ...currMap.keys()]); const changes = []; for (const name of allNames) { const before = baseMap.get(name) || { score: 0, grade: 'F' }; const after = currMap.get(name) || { score: 0, grade: 'F' }; changes.push({ name, before: { score: before.score, grade: before.grade }, after: { score: after.score, grade: after.grade }, delta: after.score - before.score, }); } return changes; }