feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
287
plugins/config-audit/scanners/lib/diff-engine.mjs
Normal file
287
plugins/config-audit/scanners/lib/diff-engine.mjs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue