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

124 lines
3.3 KiB
JavaScript

/**
* Baseline manager for config-audit.
* Stores and retrieves scanner envelopes as named baselines.
* Zero external dependencies.
*/
import { readFile, writeFile, readdir, unlink, mkdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
const BASELINES_DIR = join(homedir(), '.config-audit', 'baselines');
/**
* Get the baselines directory path.
* @returns {string}
*/
export function getBaselinesDir() {
return BASELINES_DIR;
}
/**
* Save a scanner envelope as a named baseline.
* @param {object} envelope - Full envelope from scan-orchestrator
* @param {string} [name='default'] - Baseline name
* @returns {Promise<{ path: string, name: string }>}
*/
export async function saveBaseline(envelope, name = 'default') {
await mkdir(BASELINES_DIR, { recursive: true });
const enriched = {
...envelope,
_baseline: {
saved_at: new Date().toISOString(),
target_path: envelope.meta?.target || '',
finding_count: envelope.aggregate?.total_findings || 0,
score: avgScore(envelope),
},
};
const filePath = join(BASELINES_DIR, `${name}.json`);
await writeFile(filePath, JSON.stringify(enriched, null, 2), 'utf-8');
return { path: filePath, name };
}
/**
* Load a named baseline.
* @param {string} [name='default'] - Baseline name
* @returns {Promise<object|null>} Envelope or null if not found
*/
export async function loadBaseline(name = 'default') {
const filePath = join(BASELINES_DIR, `${name}.json`);
try {
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* List all saved baselines.
* @returns {Promise<{ baselines: Array<{ name: string, savedAt: string, targetPath: string, findingCount: number, score: number }> }>}
*/
export async function listBaselines() {
try {
await stat(BASELINES_DIR);
} catch {
return { baselines: [] };
}
const entries = await readdir(BASELINES_DIR);
const baselines = [];
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
const name = entry.replace(/\.json$/, '');
const filePath = join(BASELINES_DIR, entry);
try {
const content = await readFile(filePath, 'utf-8');
const data = JSON.parse(content);
const meta = data._baseline || {};
baselines.push({
name,
savedAt: meta.saved_at || '',
targetPath: meta.target_path || '',
findingCount: meta.finding_count || 0,
score: meta.score || 0,
});
} catch {
// Skip corrupt baselines
baselines.push({ name, savedAt: '', targetPath: '', findingCount: 0, score: 0 });
}
}
return { baselines };
}
/**
* Delete a named baseline.
* @param {string} name - Baseline name
* @returns {Promise<{ deleted: boolean }>}
*/
export async function deleteBaseline(name) {
const filePath = join(BASELINES_DIR, `${name}.json`);
try {
await unlink(filePath);
return { deleted: true };
} catch {
return { deleted: false };
}
}
// --- Internal helpers ---
function avgScore(envelope) {
const scanners = envelope.scanners || [];
if (scanners.length === 0) return 0;
// Simple: count findings as proxy for score
const total = envelope.aggregate?.total_findings || 0;
// Lower findings = higher score. Cap at 100.
return Math.max(0, 100 - total * 3);
}