ktg-plugin-marketplace/plugins/config-audit/scanners/lib/diff-engine.mjs

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