287 lines
8.8 KiB
JavaScript
287 lines
8.8 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|