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
179
plugins/config-audit/scanners/lib/backup.mjs
Normal file
179
plugins/config-audit/scanners/lib/backup.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Backup library for config-audit.
|
||||
* Creates timestamped backups of config files with checksums and manifests.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, existsSync, statSync, rmSync, readFile } from 'node:fs';
|
||||
import { readFile as readFileAsync } from 'node:fs/promises';
|
||||
import { join, basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const BACKUP_ROOT = join(homedir(), '.config-audit', 'backups');
|
||||
const MAX_BACKUPS = 10;
|
||||
|
||||
/**
|
||||
* Get the backup root directory path.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBackupDir() {
|
||||
return BACKUP_ROOT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a timestamp-based backup ID.
|
||||
* @returns {string} Format: YYYYMMDD_HHMMSS
|
||||
*/
|
||||
export function generateBackupId() {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const h = String(now.getHours()).padStart(2, '0');
|
||||
const min = String(now.getMinutes()).padStart(2, '0');
|
||||
const s = String(now.getSeconds()).padStart(2, '0');
|
||||
return `${y}${m}${d}_${h}${min}${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a safe filename from a file path (replace path separators with _).
|
||||
* @param {string} filePath
|
||||
* @returns {string}
|
||||
*/
|
||||
export function safeFileName(filePath) {
|
||||
return filePath.replace(/[\\\/]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 checksum of a buffer or string.
|
||||
* @param {Buffer|string} content
|
||||
* @returns {string}
|
||||
*/
|
||||
export function checksum(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of the specified files.
|
||||
* @param {string[]} files - Array of absolute file paths to back up
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.backupId] - Override backup ID (for testing)
|
||||
* @returns {{ backupId: string, backupPath: string, manifest: object }}
|
||||
*/
|
||||
export function createBackup(files, opts = {}) {
|
||||
const backupId = opts.backupId || generateBackupId();
|
||||
const backupPath = join(BACKUP_ROOT, backupId);
|
||||
const filesDir = join(backupPath, 'files');
|
||||
|
||||
mkdirSync(filesDir, { recursive: true });
|
||||
|
||||
const manifestFiles = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!existsSync(file)) continue;
|
||||
|
||||
const safeName = safeFileName(file);
|
||||
copyFileSync(file, join(filesDir, safeName));
|
||||
|
||||
const content = readFileSync(file);
|
||||
const hash = checksum(content);
|
||||
const sizeBytes = statSync(file).size;
|
||||
|
||||
manifestFiles.push({
|
||||
originalPath: file,
|
||||
backupPath: `./files/${safeName}`,
|
||||
checksum: hash,
|
||||
sizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
created_at: new Date().toISOString(),
|
||||
backup_id: backupId,
|
||||
files: manifestFiles,
|
||||
};
|
||||
|
||||
// Write manifest as YAML-like format
|
||||
const manifestYaml = serializeManifest(manifest);
|
||||
writeFileSync(join(backupPath, 'manifest.yaml'), manifestYaml);
|
||||
|
||||
// Cleanup old backups
|
||||
cleanupOldBackups();
|
||||
|
||||
return { backupId, backupPath, manifest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize manifest to YAML-like format.
|
||||
* @param {object} manifest
|
||||
* @returns {string}
|
||||
*/
|
||||
function serializeManifest(manifest) {
|
||||
let yaml = `created_at: "${manifest.created_at}"\n`;
|
||||
yaml += `backup_id: "${manifest.backup_id}"\n`;
|
||||
yaml += `files:\n`;
|
||||
for (const f of manifest.files) {
|
||||
yaml += ` - original_path: "${f.originalPath}"\n`;
|
||||
yaml += ` backup_path: "${f.backupPath}"\n`;
|
||||
yaml += ` checksum: "${f.checksum}"\n`;
|
||||
yaml += ` size_bytes: ${f.sizeBytes}\n`;
|
||||
}
|
||||
return yaml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a manifest.yaml file content.
|
||||
* @param {string} content
|
||||
* @returns {object}
|
||||
*/
|
||||
export function parseManifest(content) {
|
||||
const result = { created_at: '', backup_id: '', files: [] };
|
||||
|
||||
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
|
||||
if (createdMatch) result.created_at = createdMatch[1];
|
||||
|
||||
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
|
||||
if (idMatch) result.backup_id = idMatch[1];
|
||||
|
||||
// Parse file entries
|
||||
const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1);
|
||||
for (const block of fileBlocks) {
|
||||
const origMatch = block.match(/^\s*"([^"]+)"/);
|
||||
const bpMatch = block.match(/backup_path:\s*"([^"]+)"/);
|
||||
const csMatch = block.match(/checksum:\s*"([^"]+)"/);
|
||||
const szMatch = block.match(/size_bytes:\s*(\d+)/);
|
||||
|
||||
if (origMatch && bpMatch && csMatch) {
|
||||
result.files.push({
|
||||
originalPath: origMatch[1],
|
||||
backupPath: bpMatch[1],
|
||||
checksum: csMatch[1],
|
||||
sizeBytes: szMatch ? parseInt(szMatch[1], 10) : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove old backups beyond MAX_BACKUPS.
|
||||
*/
|
||||
function cleanupOldBackups() {
|
||||
if (!existsSync(BACKUP_ROOT)) return;
|
||||
|
||||
const dirs = readdirSync(BACKUP_ROOT, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
.sort();
|
||||
|
||||
if (dirs.length > MAX_BACKUPS) {
|
||||
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
|
||||
for (const dir of toDelete) {
|
||||
rmSync(join(BACKUP_ROOT, dir), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { MAX_BACKUPS };
|
||||
124
plugins/config-audit/scanners/lib/baseline.mjs
Normal file
124
plugins/config-audit/scanners/lib/baseline.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
308
plugins/config-audit/scanners/lib/file-discovery.mjs
Normal file
308
plugins/config-audit/scanners/lib/file-discovery.mjs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* Config file discovery for config-audit.
|
||||
* Finds CLAUDE.md, settings.json, hooks.json, .mcp.json, rules/, plugin.json, etc.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { join, resolve, relative, extname, basename, dirname, sep } from 'node:path';
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__',
|
||||
'.next', '.nuxt', '.output', '.cache', '.turbo', '.parcel-cache',
|
||||
'vendor', 'venv', '.venv', '.tox',
|
||||
]);
|
||||
|
||||
/** Config file patterns to discover */
|
||||
const CONFIG_PATTERNS = {
|
||||
claudeMd: /^CLAUDE\.md$|^CLAUDE\.local\.md$/i,
|
||||
settingsJson: /^settings\.json$|^settings\.local\.json$/,
|
||||
mcpJson: /^\.mcp\.json$/,
|
||||
pluginJson: /^plugin\.json$/,
|
||||
hooksJson: /^hooks\.json$/,
|
||||
rulesDir: /^rules$/,
|
||||
agentsMd: /\.md$/,
|
||||
commandsMd: /\.md$/,
|
||||
skillsMd: /^SKILL\.md$/i,
|
||||
keybindings: /^keybindings\.json$/,
|
||||
claudeJson: /^\.claude\.json$/,
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover all Claude Code config files under a target path.
|
||||
* @param {string} targetPath
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxFiles=500] - max files to return
|
||||
* @param {boolean} [opts.includeGlobal=false] - also scan ~/.claude/
|
||||
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
|
||||
*
|
||||
* @typedef {{ absPath: string, relPath: string, type: string, scope: string, size: number }} ConfigFile
|
||||
*/
|
||||
export async function discoverConfigFiles(targetPath, opts = {}) {
|
||||
const maxFiles = opts.maxFiles || 2000;
|
||||
const maxDepth = opts.maxDepth || 10;
|
||||
const files = [];
|
||||
const skippedRef = { count: 0 };
|
||||
|
||||
await walkForConfig(targetPath, targetPath, files, skippedRef, maxFiles, undefined, maxDepth);
|
||||
|
||||
if (opts.includeGlobal) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const claudeDir = join(home, '.claude');
|
||||
try {
|
||||
await stat(claudeDir);
|
||||
await walkForConfig(claudeDir, claudeDir, files, skippedRef, maxFiles, 'user', maxDepth);
|
||||
} catch { /* .claude dir doesn't exist */ }
|
||||
|
||||
// ~/.claude.json
|
||||
const claudeJson = join(home, '.claude.json');
|
||||
try {
|
||||
const s = await stat(claudeJson);
|
||||
files.push({
|
||||
absPath: claudeJson,
|
||||
relPath: '.claude.json',
|
||||
type: 'claude-json',
|
||||
scope: 'user',
|
||||
size: s.size,
|
||||
});
|
||||
} catch { /* doesn't exist */ }
|
||||
}
|
||||
|
||||
return { files, skipped: skippedRef.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk directory tree looking for config files.
|
||||
*/
|
||||
async function walkForConfig(dir, basePath, files, skippedRef, maxFiles, forceScope, maxDepth) {
|
||||
if (files.length >= maxFiles) return;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) break;
|
||||
const fullPath = join(dir, entry.name);
|
||||
const rel = relative(basePath, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) {
|
||||
skippedRef.count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for .claude directory (contains settings, rules, etc.)
|
||||
if (entry.name === '.claude' || entry.name === '.claude-plugin') {
|
||||
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for rules/ inside .claude
|
||||
if (entry.name === 'rules' && dirname(rel).includes('.claude')) {
|
||||
await walkRulesDir(fullPath, basePath, files, maxFiles, forceScope || classifyScope(rel, basePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for agents/, commands/, skills/, hooks/ dirs
|
||||
if (['agents', 'commands', 'skills', 'hooks'].includes(entry.name)) {
|
||||
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into subdirectories (configurable depth limit)
|
||||
const depth = rel.split(sep).length;
|
||||
if (depth < maxDepth) {
|
||||
await walkForConfig(fullPath, basePath, files, skippedRef, maxFiles, forceScope, maxDepth);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const fileType = classifyFile(entry.name, rel);
|
||||
if (fileType) {
|
||||
let s;
|
||||
try {
|
||||
s = await stat(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
absPath: fullPath,
|
||||
relPath: rel,
|
||||
type: fileType,
|
||||
scope: forceScope || classifyScope(rel, basePath),
|
||||
size: s.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a rules directory and collect all files (including non-.md for validation).
|
||||
*/
|
||||
async function walkRulesDir(dir, basePath, files, maxFiles, scope) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) break;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isFile()) {
|
||||
let s;
|
||||
try {
|
||||
s = await stat(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
absPath: fullPath,
|
||||
relPath: relative(basePath, fullPath),
|
||||
type: 'rule',
|
||||
scope,
|
||||
size: s.size,
|
||||
});
|
||||
} else if (entry.isDirectory()) {
|
||||
await walkRulesDir(fullPath, basePath, files, maxFiles, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a file by name and path.
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function classifyFile(name, relPath) {
|
||||
if (CONFIG_PATTERNS.claudeMd.test(name)) return 'claude-md';
|
||||
if (name === 'settings.json' || name === 'settings.local.json') {
|
||||
if (relPath.includes('.claude')) return 'settings-json';
|
||||
}
|
||||
if (name === '.mcp.json') return 'mcp-json';
|
||||
if (name === 'plugin.json' && relPath.includes('.claude-plugin')) return 'plugin-json';
|
||||
if (name === 'hooks.json' && relPath.includes('hooks')) return 'hooks-json';
|
||||
if (name === 'keybindings.json') return 'keybindings-json';
|
||||
if (name === '.claude.json') return 'claude-json';
|
||||
|
||||
// Agent/command/skill markdown files
|
||||
if (name.endsWith('.md') && relPath.includes(`agents${sep}`)) return 'agent-md';
|
||||
if (name.endsWith('.md') && relPath.includes(`commands${sep}`)) return 'command-md';
|
||||
if (/^SKILL\.md$/i.test(name)) return 'skill-md';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the scope of a config file.
|
||||
* @returns {'managed' | 'user' | 'project' | 'local' | 'plugin'}
|
||||
*/
|
||||
function classifyScope(relPath, basePath) {
|
||||
if (relPath.includes('managed-settings')) return 'managed';
|
||||
if (basePath.includes(`.claude${sep}plugins`)) return 'plugin';
|
||||
if (relPath.includes('.local.')) return 'local';
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
if (basePath.startsWith(join(home, '.claude'))) return 'user';
|
||||
return 'project';
|
||||
}
|
||||
|
||||
/** Common developer directory names under $HOME */
|
||||
const DEV_DIRS = ['repos', 'projects', 'src', 'code', 'dev', 'work', 'Sites', 'Developer'];
|
||||
|
||||
/**
|
||||
* Discover all root paths for a full-machine scan.
|
||||
* Only returns paths that actually exist on the filesystem.
|
||||
* @returns {Promise<Array<{ path: string, maxDepth: number }>>}
|
||||
*/
|
||||
export async function discoverFullMachinePaths() {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const candidates = [
|
||||
// ~/.claude — deepest (plugins can be 6+ levels deep)
|
||||
{ path: join(home, '.claude'), maxDepth: 10 },
|
||||
// Managed system paths
|
||||
{ path: '/Library/Application Support/ClaudeCode', maxDepth: 5 },
|
||||
{ path: '/etc/claude-code', maxDepth: 5 },
|
||||
// Common developer directories
|
||||
...DEV_DIRS.map(d => ({ path: join(home, d), maxDepth: 5 })),
|
||||
];
|
||||
|
||||
const existing = [];
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const s = await stat(c.path);
|
||||
if (s.isDirectory()) existing.push(c);
|
||||
} catch { /* not present */ }
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover config files across multiple root paths.
|
||||
* Calls discoverConfigFiles() per root with correct basePath (preserves scope/relPath).
|
||||
* Deduplicates files by absPath — first occurrence wins.
|
||||
* @param {Array<{ path: string, maxDepth: number }>} roots
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxFiles=2000] - global max across all roots
|
||||
* @returns {Promise<{ files: ConfigFile[], skipped: number }>}
|
||||
*/
|
||||
export async function discoverConfigFilesMulti(roots, opts = {}) {
|
||||
const maxFiles = opts.maxFiles || 2000;
|
||||
const seen = new Set();
|
||||
const allFiles = [];
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const root of roots) {
|
||||
if (allFiles.length >= maxFiles) break;
|
||||
|
||||
const result = await discoverConfigFiles(root.path, {
|
||||
maxFiles: maxFiles - allFiles.length,
|
||||
maxDepth: root.maxDepth,
|
||||
});
|
||||
|
||||
totalSkipped += result.skipped;
|
||||
|
||||
for (const f of result.files) {
|
||||
if (!seen.has(f.absPath)) {
|
||||
seen.add(f.absPath);
|
||||
allFiles.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ~/.claude.json separately (single file, not a directory)
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const claudeJson = join(home, '.claude.json');
|
||||
if (allFiles.length < maxFiles && !seen.has(claudeJson)) {
|
||||
try {
|
||||
const s = await stat(claudeJson);
|
||||
allFiles.push({
|
||||
absPath: claudeJson,
|
||||
relPath: '.claude.json',
|
||||
type: 'claude-json',
|
||||
scope: 'user',
|
||||
size: s.size,
|
||||
});
|
||||
} catch { /* doesn't exist */ }
|
||||
}
|
||||
|
||||
return { files: allFiles, skipped: totalSkipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file as UTF-8 text. Returns null on error or if binary.
|
||||
* @param {string} absPath
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function readTextFile(absPath) {
|
||||
try {
|
||||
const content = await readFile(absPath, 'utf-8');
|
||||
// Check for binary (null bytes in first 8KB)
|
||||
const sample = content.slice(0, 8192);
|
||||
if (sample.includes('\0')) return null;
|
||||
return content;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
121
plugins/config-audit/scanners/lib/output.mjs
Normal file
121
plugins/config-audit/scanners/lib/output.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Finding and result builders for config-audit scanners.
|
||||
* Finding IDs: CA-{SCANNER}-{NNN} (e.g. CA-CML-001)
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { riskScore, riskBand, verdict } from './severity.mjs';
|
||||
|
||||
let findingCounter = 0;
|
||||
|
||||
/** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */
|
||||
export function resetCounter() {
|
||||
findingCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a finding object with auto-incremented ID.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.scanner - 3-letter scanner prefix (CML, SET, HKV, RUL, etc.)
|
||||
* @param {string} opts.severity - critical | high | medium | low | info
|
||||
* @param {string} opts.title
|
||||
* @param {string} opts.description
|
||||
* @param {string} [opts.file] - file path where finding was detected
|
||||
* @param {number} [opts.line] - line number
|
||||
* @param {string} [opts.evidence] - relevant snippet
|
||||
* @param {string} [opts.category] - quality category
|
||||
* @param {string} [opts.recommendation] - suggested fix
|
||||
* @param {boolean} [opts.autoFixable] - can be auto-fixed
|
||||
* @returns {object}
|
||||
*/
|
||||
export function finding(opts) {
|
||||
findingCounter++;
|
||||
const id = `CA-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
|
||||
return {
|
||||
id,
|
||||
scanner: opts.scanner,
|
||||
severity: opts.severity,
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
file: opts.file || null,
|
||||
line: opts.line || null,
|
||||
evidence: opts.evidence || null,
|
||||
category: opts.category || null,
|
||||
recommendation: opts.recommendation || null,
|
||||
autoFixable: opts.autoFixable || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scanner result envelope.
|
||||
* @param {string} scannerName - 3-letter prefix
|
||||
* @param {'ok' | 'error' | 'skipped'} status
|
||||
* @param {object[]} findings
|
||||
* @param {number} filesScanned
|
||||
* @param {number} durationMs
|
||||
* @param {string} [errorMsg]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function scannerResult(scannerName, status, findings, filesScanned, durationMs, errorMsg) {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
for (const f of findings) {
|
||||
if (counts[f.severity] !== undefined) {
|
||||
counts[f.severity]++;
|
||||
}
|
||||
}
|
||||
const result = {
|
||||
scanner: scannerName,
|
||||
status,
|
||||
files_scanned: filesScanned,
|
||||
duration_ms: durationMs,
|
||||
findings,
|
||||
counts,
|
||||
};
|
||||
if (errorMsg) result.error = errorMsg;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the top-level output envelope combining all scanner results.
|
||||
* @param {string} targetPath
|
||||
* @param {object[]} scannerResults
|
||||
* @param {number} totalDurationMs
|
||||
* @returns {object}
|
||||
*/
|
||||
export function envelope(targetPath, scannerResults, totalDurationMs) {
|
||||
const aggregate = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
let totalFindings = 0;
|
||||
let scannersOk = 0;
|
||||
let scannersError = 0;
|
||||
let scannersSkipped = 0;
|
||||
|
||||
for (const r of scannerResults) {
|
||||
for (const sev of Object.keys(aggregate)) {
|
||||
aggregate[sev] += (r.counts[sev] || 0);
|
||||
}
|
||||
totalFindings += r.findings.length;
|
||||
if (r.status === 'ok') scannersOk++;
|
||||
else if (r.status === 'error') scannersError++;
|
||||
else if (r.status === 'skipped') scannersSkipped++;
|
||||
}
|
||||
|
||||
return {
|
||||
meta: {
|
||||
target: targetPath,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '2.2.0',
|
||||
tool: 'config-audit',
|
||||
},
|
||||
scanners: scannerResults,
|
||||
aggregate: {
|
||||
total_findings: totalFindings,
|
||||
counts: aggregate,
|
||||
risk_score: riskScore(aggregate),
|
||||
risk_band: riskBand(riskScore(aggregate)),
|
||||
verdict: verdict(aggregate),
|
||||
scanners_ok: scannersOk,
|
||||
scanners_error: scannersError,
|
||||
scanners_skipped: scannersSkipped,
|
||||
},
|
||||
};
|
||||
}
|
||||
278
plugins/config-audit/scanners/lib/report-generator.mjs
Normal file
278
plugins/config-audit/scanners/lib/report-generator.mjs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* Unified report generator for config-audit.
|
||||
* Produces markdown reports from posture, drift, and plugin health results.
|
||||
* Template strings are embedded in JS — no separate .md files to parse.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
const MAX_FINDINGS_PER_SCANNER = 10;
|
||||
const MAX_REPORT_LINES = 500;
|
||||
|
||||
/**
|
||||
* Generate a posture report in markdown.
|
||||
* @param {object} postureResult - Output from runPosture()
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generatePostureReport(postureResult) {
|
||||
const {
|
||||
areas, overallGrade, scannerEnvelope,
|
||||
} = postureResult;
|
||||
const opportunityCount = postureResult.opportunityCount ?? 0;
|
||||
|
||||
// Quality areas only (exclude Feature Coverage)
|
||||
const qualityAreas = areas.filter(a => a.name !== 'Feature Coverage');
|
||||
const avgScore = qualityAreas.length > 0
|
||||
? Math.round(qualityAreas.reduce((s, a) => s + a.score, 0) / qualityAreas.length)
|
||||
: 0;
|
||||
|
||||
const lines = [];
|
||||
const ts = scannerEnvelope?.meta?.timestamp || new Date().toISOString();
|
||||
const target = scannerEnvelope?.meta?.target || 'unknown';
|
||||
|
||||
lines.push('## Health Assessment');
|
||||
lines.push('');
|
||||
lines.push(`> **Date:** ${ts.split('T')[0]} `);
|
||||
lines.push(`> **Target:** \`${target}\` `);
|
||||
lines.push('');
|
||||
|
||||
// Score summary
|
||||
lines.push('### Score Summary');
|
||||
lines.push('');
|
||||
lines.push('| Metric | Value |');
|
||||
lines.push('|--------|-------|');
|
||||
lines.push(`| Health Grade | **${overallGrade}** (${avgScore}/100) |`);
|
||||
lines.push(`| Areas Scanned | ${qualityAreas.length} |`);
|
||||
if (opportunityCount > 0) {
|
||||
lines.push(`| Opportunities | ${opportunityCount} features available |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Area breakdown
|
||||
lines.push('### Area Breakdown');
|
||||
lines.push('');
|
||||
lines.push('| Area | Grade | Score | Findings |');
|
||||
lines.push('|------|-------|-------|----------|');
|
||||
for (const a of qualityAreas) {
|
||||
lines.push(`| ${a.name} | ${a.grade} | ${a.score} | ${a.findingCount} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Opportunities pointer (replaces Top Actions)
|
||||
if (opportunityCount > 0) {
|
||||
lines.push(`> Run \`/config-audit feature-gap\` for ${opportunityCount} context-aware recommendations.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Findings per scanner (collapsed)
|
||||
if (scannerEnvelope?.scanners) {
|
||||
lines.push('### Findings by Scanner');
|
||||
lines.push('');
|
||||
for (const sr of scannerEnvelope.scanners) {
|
||||
if (sr.findings.length === 0) continue;
|
||||
lines.push(`<details>`);
|
||||
lines.push(`<summary>${sr.scanner} — ${sr.findings.length} finding(s)</summary>`);
|
||||
lines.push('');
|
||||
const show = sr.findings.slice(0, MAX_FINDINGS_PER_SCANNER);
|
||||
for (const f of show) {
|
||||
lines.push(`- \`[${f.severity}]\` ${f.title}${f.file ? ` (${f.file})` : ''}`);
|
||||
}
|
||||
if (sr.findings.length > MAX_FINDINGS_PER_SCANNER) {
|
||||
lines.push(`- _...and ${sr.findings.length - MAX_FINDINGS_PER_SCANNER} more_`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('</details>');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a drift report in markdown.
|
||||
* @param {object} diffResult - Output from diffEnvelopes()
|
||||
* @param {string} baselineName - Name of baseline used
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateDriftReport(diffResult, baselineName) {
|
||||
const lines = [];
|
||||
const { summary, scoreChange, newFindings, resolvedFindings, areaChanges } = diffResult;
|
||||
|
||||
const trendIcon = summary.trend === 'improving' ? '↑'
|
||||
: summary.trend === 'degrading' ? '↓' : '→';
|
||||
const trendLabel = summary.trend.charAt(0).toUpperCase() + summary.trend.slice(1);
|
||||
|
||||
lines.push('## Drift Report');
|
||||
lines.push('');
|
||||
lines.push(`> **Baseline:** \`${baselineName}\` `);
|
||||
lines.push(`> **Trend:** ${trendIcon} ${trendLabel} `);
|
||||
lines.push('');
|
||||
|
||||
// Score delta
|
||||
const sc = scoreChange;
|
||||
const deltaSign = sc.delta > 0 ? '+' : '';
|
||||
lines.push('### Score Change');
|
||||
lines.push('');
|
||||
lines.push(`**${sc.before.grade}** (${sc.before.score}) ${trendIcon} **${sc.after.grade}** (${sc.after.score}) — ${deltaSign}${sc.delta} points`);
|
||||
lines.push('');
|
||||
|
||||
// New findings
|
||||
if (newFindings.length > 0) {
|
||||
lines.push('### New Findings');
|
||||
lines.push('');
|
||||
lines.push('| Severity | Title | File |');
|
||||
lines.push('|----------|-------|------|');
|
||||
for (const f of newFindings.slice(0, 20)) {
|
||||
lines.push(`| \`${f.severity}\` | ${f.title} | ${f.file || '-'} |`);
|
||||
}
|
||||
if (newFindings.length > 20) {
|
||||
lines.push(`| | _...and ${newFindings.length - 20} more_ | |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Resolved findings
|
||||
if (resolvedFindings.length > 0) {
|
||||
lines.push('### Resolved Findings');
|
||||
lines.push('');
|
||||
lines.push('| Severity | Title |');
|
||||
lines.push('|----------|-------|');
|
||||
for (const f of resolvedFindings.slice(0, 20)) {
|
||||
lines.push(`| \`${f.severity}\` | ${f.title} |`);
|
||||
}
|
||||
if (resolvedFindings.length > 20) {
|
||||
lines.push(`| | _...and ${resolvedFindings.length - 20} more_ |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Area changes
|
||||
const changed = (areaChanges || []).filter(a => a.delta !== 0);
|
||||
if (changed.length > 0) {
|
||||
lines.push('### Area Changes');
|
||||
lines.push('');
|
||||
lines.push('| Area | Before | After | Delta |');
|
||||
lines.push('|------|--------|-------|-------|');
|
||||
for (const a of changed) {
|
||||
const sign = a.delta > 0 ? '+' : '';
|
||||
lines.push(`| ${a.name} | ${a.before.grade} (${a.before.score}) | ${a.after.grade} (${a.after.score}) | ${sign}${a.delta} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a plugin health report in markdown.
|
||||
* @param {object} scanResult - Scanner result from plugin-health-scanner scan()
|
||||
* @param {Array<{ name: string, findings: object[], commandCount: number, agentCount: number }>} pluginResults
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generatePluginHealthReport(scanResult, pluginResults) {
|
||||
const lines = [];
|
||||
|
||||
lines.push('## Plugin Health');
|
||||
lines.push('');
|
||||
|
||||
if (!pluginResults || pluginResults.length === 0) {
|
||||
lines.push('_No plugins found._');
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Plugin summary table
|
||||
lines.push('| Plugin | Grade | Score | Commands | Agents | Issues |');
|
||||
lines.push('|--------|-------|-------|----------|--------|--------|');
|
||||
for (const p of pluginResults) {
|
||||
const issueCount = p.findings.length;
|
||||
const score = Math.max(0, 100 - issueCount * 10);
|
||||
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
||||
lines.push(`| ${p.name} | ${grade} | ${score} | ${p.commandCount} | ${p.agentCount} | ${issueCount} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Per-plugin findings
|
||||
for (const p of pluginResults) {
|
||||
if (p.findings.length === 0) continue;
|
||||
lines.push(`<details>`);
|
||||
lines.push(`<summary>${p.name} — ${p.findings.length} issue(s)</summary>`);
|
||||
lines.push('');
|
||||
for (const f of p.findings.slice(0, MAX_FINDINGS_PER_SCANNER)) {
|
||||
lines.push(`- \`[${f.severity}]\` ${f.title}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('</details>');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Cross-plugin issues (from scanResult.findings where title contains "Cross-plugin")
|
||||
const crossPlugin = (scanResult?.findings || []).filter(f => f.title.includes('Cross-plugin'));
|
||||
if (crossPlugin.length > 0) {
|
||||
lines.push('### Cross-Plugin Issues');
|
||||
lines.push('');
|
||||
for (const f of crossPlugin) {
|
||||
lines.push(`- \`[${f.severity}]\` ${f.title}: ${f.description}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unified full report combining all sections.
|
||||
* Each input is optional (null = skip that section).
|
||||
* @param {object|null} postureResult - From runPosture()
|
||||
* @param {object|null} driftResult - { diff, baselineName } from diffEnvelopes()
|
||||
* @param {object|null} pluginHealthResult - { scanResult, pluginResults } from plugin-health-scanner
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateFullReport(postureResult, driftResult, pluginHealthResult) {
|
||||
const lines = [];
|
||||
|
||||
lines.push('# Config-Audit Report');
|
||||
lines.push('');
|
||||
lines.push(`_Generated: ${new Date().toISOString().split('T')[0]}_`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
if (postureResult) {
|
||||
lines.push(generatePostureReport(postureResult));
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (driftResult) {
|
||||
lines.push(generateDriftReport(driftResult.diff, driftResult.baselineName));
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (pluginHealthResult) {
|
||||
lines.push(generatePluginHealthReport(
|
||||
pluginHealthResult.scanResult,
|
||||
pluginHealthResult.pluginResults,
|
||||
));
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (!postureResult && !driftResult && !pluginHealthResult) {
|
||||
lines.push('_No data provided for report._');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Truncate if over limit
|
||||
const result = lines.join('\n');
|
||||
const resultLines = result.split('\n');
|
||||
if (resultLines.length > MAX_REPORT_LINES) {
|
||||
const truncated = resultLines.slice(0, MAX_REPORT_LINES);
|
||||
truncated.push('');
|
||||
truncated.push(`_Report truncated at ${MAX_REPORT_LINES} lines. Run individual reports for full details._`);
|
||||
return truncated.join('\n');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
310
plugins/config-audit/scanners/lib/scoring.mjs
Normal file
310
plugins/config-audit/scanners/lib/scoring.mjs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
/**
|
||||
* Scoring, maturity, and posture assessment for config-audit.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { gradeFromPassRate } from './severity.mjs';
|
||||
|
||||
// --- Tier weights for utilization calculation ---
|
||||
const TIER_WEIGHTS = { t1: 3, t2: 2, t3: 1, t4: 1 };
|
||||
const TIER_COUNTS = { t1: 5, t2: 7, t3: 8, t4: 5 };
|
||||
const TOTAL_DIMENSIONS = 25;
|
||||
const MAX_WEIGHTED = Object.entries(TIER_COUNTS).reduce(
|
||||
(sum, [tier, count]) => sum + count * TIER_WEIGHTS[tier],
|
||||
0,
|
||||
); // 5*3 + 7*2 + 8*1 + 5*1 = 42
|
||||
|
||||
/**
|
||||
* Calculate weighted utilization from GAP scanner findings.
|
||||
* @param {object[]} gapFindings - Array of GAP scanner findings (each has .category = t1|t2|t3|t4)
|
||||
* @param {number} [totalDimensions=25]
|
||||
* @returns {{ score: number, overhang: number }}
|
||||
*/
|
||||
export function calculateUtilization(gapFindings, totalDimensions = TOTAL_DIMENSIONS) {
|
||||
// Count gaps per tier
|
||||
const gapsByTier = { t1: 0, t2: 0, t3: 0, t4: 0 };
|
||||
for (const f of gapFindings) {
|
||||
const tier = f.category;
|
||||
if (tier in gapsByTier) gapsByTier[tier]++;
|
||||
}
|
||||
|
||||
// Present (non-gap) weight
|
||||
let presentWeight = 0;
|
||||
for (const [tier, totalCount] of Object.entries(TIER_COUNTS)) {
|
||||
const presentCount = totalCount - gapsByTier[tier];
|
||||
presentWeight += presentCount * TIER_WEIGHTS[tier];
|
||||
}
|
||||
|
||||
const score = Math.round((presentWeight / MAX_WEIGHTED) * 100);
|
||||
return { score, overhang: 100 - score };
|
||||
}
|
||||
|
||||
// --- Maturity levels ---
|
||||
const MATURITY_LEVELS = [
|
||||
{ level: 0, name: 'Bare', description: 'No CLAUDE.md, default everything' },
|
||||
{ level: 1, name: 'Configured', description: 'CLAUDE.md + basic settings' },
|
||||
{ level: 2, name: 'Structured', description: 'Rules, skills, hooks' },
|
||||
{ level: 3, name: 'Automated', description: 'MCP, custom agents, diverse hooks' },
|
||||
{ level: 4, name: 'Governed', description: 'Plugins, managed settings, full monitoring' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Determine config maturity level (threshold-based: highest level where ALL requirements met).
|
||||
* @param {object[]} gapFindings - GAP scanner findings
|
||||
* @param {{ files: Array<{ type: string, absPath?: string, scope?: string }> }} discovery
|
||||
* @returns {{ level: number, name: string, description: string }}
|
||||
*/
|
||||
export function determineMaturityLevel(gapFindings, discovery) {
|
||||
const gapIds = new Set(gapFindings.map(f => {
|
||||
// Extract the gap check id from the title — match against known titles
|
||||
return findGapId(f);
|
||||
}));
|
||||
|
||||
const has = (id) => !gapIds.has(id); // feature is present if NOT in gaps
|
||||
|
||||
// Level 1: CLAUDE.md present
|
||||
if (!has('t1_1')) return MATURITY_LEVELS[0];
|
||||
|
||||
// Level 2: Level 1 + permissions + hooks + (modular OR path-rules)
|
||||
const level2 = has('t1_2') && has('t1_3') && (has('t2_2') || has('t2_3'));
|
||||
if (!level2) return MATURITY_LEVELS[1];
|
||||
|
||||
// Level 3: Level 2 + MCP + hook diversity + custom subagents
|
||||
const level3 = has('t1_5') && has('t2_5') && has('t2_6');
|
||||
if (!level3) return MATURITY_LEVELS[2];
|
||||
|
||||
// Level 4: Level 3 + project MCP in git + custom plugin
|
||||
const level4 = has('t4_1') && has('t4_2');
|
||||
if (!level4) return MATURITY_LEVELS[3];
|
||||
|
||||
return MATURITY_LEVELS[4];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a GAP finding to its gap check ID based on known title→id mapping.
|
||||
* @param {object} finding
|
||||
* @returns {string}
|
||||
*/
|
||||
function findGapId(finding) {
|
||||
return TITLE_TO_ID[finding.title] || 'unknown';
|
||||
}
|
||||
|
||||
/** Title→ID mapping for all 25 gap checks */
|
||||
const TITLE_TO_ID = {
|
||||
'No CLAUDE.md file': 't1_1',
|
||||
'No permissions configured': 't1_2',
|
||||
'No hooks configured': 't1_3',
|
||||
'No custom skills or commands': 't1_4',
|
||||
'No MCP servers configured': 't1_5',
|
||||
'Settings only at one scope': 't2_1',
|
||||
'CLAUDE.md not modular': 't2_2',
|
||||
'No path-scoped rules': 't2_3',
|
||||
'Auto-memory explicitly disabled': 't2_4',
|
||||
'Low hook diversity': 't2_5',
|
||||
'No custom subagents': 't2_6',
|
||||
'No model configuration': 't2_7',
|
||||
'No status line configured': 't3_1',
|
||||
'No custom keybindings': 't3_2',
|
||||
'Using default output style': 't3_3',
|
||||
'No worktree workflow': 't3_4',
|
||||
'No advanced skill frontmatter': 't3_5',
|
||||
'No subagent isolation': 't3_6',
|
||||
'No dynamic skill context': 't3_7',
|
||||
'No autoMode classifier': 't3_8',
|
||||
'No project .mcp.json in git': 't4_1',
|
||||
'No custom plugin': 't4_2',
|
||||
'Agent teams not enabled': 't4_3',
|
||||
'No managed settings': 't4_4',
|
||||
'No LSP plugins': 't4_5',
|
||||
};
|
||||
|
||||
// --- Segments ---
|
||||
const SEGMENTS = [
|
||||
{ min: 81, segment: 'Top Performer', description: 'Exceptional configuration — leveraging most of Claude Code\'s capabilities' },
|
||||
{ min: 65, segment: 'Strong', description: 'Well-configured — using advanced features effectively' },
|
||||
{ min: 45, segment: 'Competent', description: 'Solid foundation — room to leverage more features' },
|
||||
{ min: 25, segment: 'Developing', description: 'Basic setup — significant features untapped' },
|
||||
{ min: 0, segment: 'Beginner', description: 'Minimal configuration — most capabilities unused' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Determine segment from utilization score.
|
||||
* @param {number} score - 0-100
|
||||
* @param {number} [_maturityLevel] - unused, kept for API compatibility
|
||||
* @returns {{ segment: string, description: string }}
|
||||
*/
|
||||
export function determineSegment(score, _maturityLevel) {
|
||||
for (const s of SEGMENTS) {
|
||||
if (score >= s.min) return { segment: s.segment, description: s.description };
|
||||
}
|
||||
return SEGMENTS[SEGMENTS.length - 1];
|
||||
}
|
||||
|
||||
// --- Area scoring ---
|
||||
const SCANNER_AREA_MAP = {
|
||||
CML: 'CLAUDE.md',
|
||||
SET: 'Settings',
|
||||
HKV: 'Hooks',
|
||||
RUL: 'Rules',
|
||||
MCP: 'MCP',
|
||||
IMP: 'Imports',
|
||||
CNF: 'Conflicts',
|
||||
GAP: 'Feature Coverage',
|
||||
};
|
||||
|
||||
/**
|
||||
* Score per config area from scanner results.
|
||||
* @param {object[]} scannerResults - Array of scanner result objects from envelope.scanners
|
||||
* @returns {{ areas: Array<{ name: string, grade: string, score: number, findingCount: number }>, overallGrade: string }}
|
||||
*/
|
||||
export function scoreByArea(scannerResults) {
|
||||
const areas = [];
|
||||
|
||||
for (const result of scannerResults) {
|
||||
const name = SCANNER_AREA_MAP[result.scanner] || result.scanner;
|
||||
const findingCount = result.findings.length;
|
||||
|
||||
let score;
|
||||
if (result.scanner === 'GAP') {
|
||||
// Feature coverage: utilization-based
|
||||
const util = calculateUtilization(result.findings);
|
||||
score = util.score;
|
||||
} else {
|
||||
// Quality-based: fewer findings = higher pass rate
|
||||
// Use a reasonable max checks per scanner for pass rate
|
||||
const maxChecks = Math.max(findingCount + 5, 10);
|
||||
const passRate = ((maxChecks - findingCount) / maxChecks) * 100;
|
||||
score = Math.round(passRate);
|
||||
}
|
||||
|
||||
const grade = gradeFromPassRate(score);
|
||||
areas.push({ name, grade, score, findingCount });
|
||||
}
|
||||
|
||||
// Overall grade: quality areas only (exclude GAP — feature coverage is informational, not a quality issue)
|
||||
const qualityAreas = areas.filter(a => a.name !== 'Feature Coverage');
|
||||
const totalScore = qualityAreas.reduce((sum, a) => sum + a.score, 0);
|
||||
const avgScore = qualityAreas.length > 0 ? Math.round(totalScore / qualityAreas.length) : 0;
|
||||
const overallGrade = gradeFromPassRate(avgScore);
|
||||
|
||||
return { areas, overallGrade };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive top 3 actions from GAP findings (T1 first, then T2).
|
||||
* @param {object[]} gapFindings
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function topActions(gapFindings) {
|
||||
const tierOrder = ['t1', 't2', 't3', 't4'];
|
||||
const sorted = [...gapFindings].sort(
|
||||
(a, b) => tierOrder.indexOf(a.category) - tierOrder.indexOf(b.category),
|
||||
);
|
||||
return sorted.slice(0, 3).map(f => f.recommendation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a terminal-friendly scorecard string (v2 format — kept for backward compat).
|
||||
* @param {{ areas: Array<{ name: string, grade: string, score: number }>, overallGrade: string }} areaScores
|
||||
* @param {{ score: number, overhang: number }} utilization
|
||||
* @param {{ level: number, name: string }} maturity
|
||||
* @param {{ segment: string }} segment
|
||||
* @param {string[]} actions
|
||||
* @returns {string}
|
||||
* @deprecated Use generateHealthScorecard for v3+ terminal output
|
||||
*/
|
||||
export function generateScorecard(areaScores, utilization, maturity, segment, actions) {
|
||||
// Bug fix: exclude GAP from displayed avgScore (was inconsistent with overallGrade)
|
||||
const qualityAreas = areaScores.areas.filter(a => a.name !== 'Feature Coverage');
|
||||
const avgScore = qualityAreas.length > 0
|
||||
? Math.round(qualityAreas.reduce((s, a) => s + a.score, 0) / qualityAreas.length)
|
||||
: 0;
|
||||
|
||||
const lines = [];
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
lines.push(' Config-Audit Posture Score');
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
lines.push('');
|
||||
lines.push(` Overall: ${areaScores.overallGrade} (${avgScore}/100) Maturity: Level ${maturity.level} (${maturity.name})`);
|
||||
lines.push(` Segment: ${segment.segment} Utilization: ${utilization.score}%`);
|
||||
lines.push('');
|
||||
lines.push(' Area Scores');
|
||||
lines.push(' ───────────');
|
||||
|
||||
// Format areas in 2-column layout
|
||||
const areas = areaScores.areas;
|
||||
for (let i = 0; i < areas.length; i += 2) {
|
||||
const left = areas[i];
|
||||
const right = areas[i + 1];
|
||||
const leftStr = ` ${left.name} ${'.'.repeat(Math.max(1, 20 - left.name.length))} ${left.grade} (${left.score})`;
|
||||
if (right) {
|
||||
const rightStr = `${right.name} ${'.'.repeat(Math.max(1, 20 - right.name.length))} ${right.grade} (${right.score})`;
|
||||
lines.push(`${leftStr.padEnd(35)}${rightStr}`);
|
||||
} else {
|
||||
lines.push(leftStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(' Top 3 Actions');
|
||||
lines.push(' ─────────────');
|
||||
for (let i = 0; i < actions.length; i++) {
|
||||
lines.push(` ${i + 1}. ${actions[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a v3 health-focused terminal scorecard.
|
||||
* Shows only the 7 quality areas — no utilization, maturity, or segment.
|
||||
* @param {{ areas: Array<{ name: string, grade: string, score: number }>, overallGrade: string }} areaScores
|
||||
* @param {number} opportunityCount - Number of GAP findings (shown as opportunity count)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateHealthScorecard(areaScores, opportunityCount) {
|
||||
const qualityAreas = areaScores.areas.filter(a => a.name !== 'Feature Coverage');
|
||||
const avgScore = qualityAreas.length > 0
|
||||
? Math.round(qualityAreas.reduce((s, a) => s + a.score, 0) / qualityAreas.length)
|
||||
: 0;
|
||||
|
||||
const lines = [];
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
lines.push(' Config-Audit Health Score');
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
lines.push('');
|
||||
lines.push(` Health: ${areaScores.overallGrade} (${avgScore}/100) ${qualityAreas.length} areas scanned`);
|
||||
lines.push('');
|
||||
lines.push(' Area Scores');
|
||||
lines.push(' ───────────');
|
||||
|
||||
// Format areas in 2-column layout (quality areas only)
|
||||
for (let i = 0; i < qualityAreas.length; i += 2) {
|
||||
const left = qualityAreas[i];
|
||||
const right = qualityAreas[i + 1];
|
||||
const leftStr = ` ${left.name} ${'.'.repeat(Math.max(1, 20 - left.name.length))} ${left.grade} (${left.score})`;
|
||||
if (right) {
|
||||
const rightStr = `${right.name} ${'.'.repeat(Math.max(1, 20 - right.name.length))} ${right.grade} (${right.score})`;
|
||||
lines.push(`${leftStr.padEnd(35)}${rightStr}`);
|
||||
} else {
|
||||
lines.push(leftStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (opportunityCount > 0) {
|
||||
lines.push('');
|
||||
lines.push(` ${opportunityCount} ${opportunityCount === 1 ? 'opportunity' : 'opportunities'} available — run /config-audit feature-gap for recommendations`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export { TITLE_TO_ID, TIER_WEIGHTS, TIER_COUNTS, MAX_WEIGHTED, MATURITY_LEVELS, SEGMENTS };
|
||||
75
plugins/config-audit/scanners/lib/severity.mjs
Normal file
75
plugins/config-audit/scanners/lib/severity.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Severity constants, risk scoring, and verdict logic for config-audit scanners.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
export const SEVERITY = Object.freeze({
|
||||
critical: 'critical',
|
||||
high: 'high',
|
||||
medium: 'medium',
|
||||
low: 'low',
|
||||
info: 'info',
|
||||
});
|
||||
|
||||
const WEIGHTS = { critical: 25, high: 10, medium: 4, low: 1, info: 0 };
|
||||
|
||||
/**
|
||||
* Calculate a 0-100 risk score from severity counts.
|
||||
* @param {{ critical?: number, high?: number, medium?: number, low?: number, info?: number }} counts
|
||||
* @returns {number}
|
||||
*/
|
||||
export function riskScore(counts) {
|
||||
let score = 0;
|
||||
for (const [sev, weight] of Object.entries(WEIGHTS)) {
|
||||
score += (counts[sev] || 0) * weight;
|
||||
}
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine overall verdict from severity counts.
|
||||
* @param {{ critical?: number, high?: number, medium?: number, low?: number, info?: number }} counts
|
||||
* @returns {'FAIL' | 'WARNING' | 'PASS'}
|
||||
*/
|
||||
export function verdict(counts) {
|
||||
const score = riskScore(counts);
|
||||
if ((counts.critical || 0) >= 1 || score >= 61) return 'FAIL';
|
||||
if ((counts.high || 0) >= 1 || score >= 21) return 'WARNING';
|
||||
return 'PASS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a risk score to a human-readable band.
|
||||
* @param {number} score
|
||||
* @returns {'Low' | 'Medium' | 'High' | 'Critical' | 'Extreme'}
|
||||
*/
|
||||
export function riskBand(score) {
|
||||
if (score <= 10) return 'Low';
|
||||
if (score <= 30) return 'Medium';
|
||||
if (score <= 60) return 'High';
|
||||
if (score <= 80) return 'Critical';
|
||||
return 'Extreme';
|
||||
}
|
||||
|
||||
/**
|
||||
* Grade from a quality pass rate (0-100%).
|
||||
* @param {number} passRate - 0-100
|
||||
* @returns {'A' | 'B' | 'C' | 'D' | 'F'}
|
||||
*/
|
||||
export function gradeFromPassRate(passRate) {
|
||||
if (passRate >= 90) return 'A';
|
||||
if (passRate >= 75) return 'B';
|
||||
if (passRate >= 60) return 'C';
|
||||
if (passRate >= 40) return 'D';
|
||||
return 'F';
|
||||
}
|
||||
|
||||
/** Config audit quality categories */
|
||||
export const QUALITY_CATEGORIES = Object.freeze({
|
||||
STRUCTURE: 'Structure & Format',
|
||||
CONTENT: 'Content Quality',
|
||||
HIERARCHY: 'Hierarchy & Scope',
|
||||
SECURITY: 'Security',
|
||||
FEATURES: 'Feature Utilization',
|
||||
COHERENCE: 'Cross-file Coherence',
|
||||
});
|
||||
74
plugins/config-audit/scanners/lib/string-utils.mjs
Normal file
74
plugins/config-audit/scanners/lib/string-utils.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* String utilities for config-audit scanners.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Count lines in a string.
|
||||
* @param {string} s
|
||||
* @returns {number}
|
||||
*/
|
||||
export function lineCount(s) {
|
||||
if (!s) return 0;
|
||||
return s.split('\n').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to maxLen chars with ellipsis.
|
||||
* @param {string} s
|
||||
* @param {number} [maxLen=100]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function truncate(s, maxLen = 100) {
|
||||
if (!s || s.length <= maxLen) return s || '';
|
||||
return s.slice(0, maxLen - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two strings have >threshold% content similarity (word overlap).
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @param {number} [threshold=0.8]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSimilar(a, b, threshold = 0.8) {
|
||||
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
||||
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return false;
|
||||
let overlap = 0;
|
||||
for (const w of wordsA) {
|
||||
if (wordsB.has(w)) overlap++;
|
||||
}
|
||||
const similarity = overlap / Math.min(wordsA.size, wordsB.size);
|
||||
return similarity >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all key-like patterns from a settings.json or similar config.
|
||||
* @param {object} obj
|
||||
* @param {string} [prefix='']
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function extractKeys(obj, prefix = '') {
|
||||
const keys = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
keys.push(fullKey);
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
keys.push(...extractKeys(value, fullKey));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a file path for comparison (resolve ~, handle trailing slashes).
|
||||
* @param {string} p
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizePath(p) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
let normalized = p.replace(/^~/, home);
|
||||
normalized = normalized.replace(/[/\\]+$/, '');
|
||||
return normalized;
|
||||
}
|
||||
154
plugins/config-audit/scanners/lib/suppression.mjs
Normal file
154
plugins/config-audit/scanners/lib/suppression.mjs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Suppression engine for config-audit.
|
||||
* Lets users suppress known false positives via .config-audit-ignore files.
|
||||
* Supports exact IDs (CA-CML-001) and glob patterns (CA-SET-*).
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/**
|
||||
* Load suppressions from .config-audit-ignore files.
|
||||
* Searches targetPath first, then ~/.claude/config-audit/.
|
||||
* Project-level file takes precedence (loaded first).
|
||||
* @param {string} targetPath - Project root to search
|
||||
* @returns {Promise<{ suppressions: Array<{ pattern: string, comment: string }>, source: string }>}
|
||||
*/
|
||||
export async function loadSuppressions(targetPath) {
|
||||
const sources = [
|
||||
{ path: join(targetPath, '.config-audit-ignore'), label: 'project' },
|
||||
{ path: join(homedir(), '.config-audit', '.config-audit-ignore'), label: 'global' },
|
||||
];
|
||||
|
||||
for (const src of sources) {
|
||||
try {
|
||||
const content = await readFile(src.path, 'utf-8');
|
||||
const suppressions = parseIgnoreFile(content);
|
||||
return { suppressions, source: src.label };
|
||||
} catch {
|
||||
// File doesn't exist — try next
|
||||
}
|
||||
}
|
||||
|
||||
return { suppressions: [], source: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .config-audit-ignore file into suppression entries.
|
||||
* @param {string} content - File content
|
||||
* @returns {Array<{ pattern: string, comment: string }>}
|
||||
*/
|
||||
export function parseIgnoreFile(content) {
|
||||
const suppressions = [];
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
// Skip empty lines and comment-only lines
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
// Split on first # for inline comment
|
||||
const hashIdx = line.indexOf('#');
|
||||
let pattern, comment;
|
||||
if (hashIdx > 0) {
|
||||
pattern = line.slice(0, hashIdx).trim();
|
||||
comment = line.slice(hashIdx + 1).trim();
|
||||
} else {
|
||||
pattern = line;
|
||||
comment = '';
|
||||
}
|
||||
|
||||
// Validate pattern looks like a finding ID or glob
|
||||
if (/^CA-[A-Z]{2,4}[-*\d]+/.test(pattern) || /^CA-[A-Z]{2,4}-\*$/.test(pattern)) {
|
||||
suppressions.push({ pattern, comment });
|
||||
}
|
||||
}
|
||||
|
||||
return suppressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply suppressions to a findings array.
|
||||
* @param {object[]} findings - Array of finding objects with .id
|
||||
* @param {Array<{ pattern: string, comment: string }>} suppressions
|
||||
* @returns {{ active: object[], suppressed: object[] }}
|
||||
*/
|
||||
export function applySuppressions(findings, suppressions) {
|
||||
if (!suppressions || suppressions.length === 0) {
|
||||
return { active: [...findings], suppressed: [] };
|
||||
}
|
||||
|
||||
const active = [];
|
||||
const suppressed = [];
|
||||
|
||||
for (const f of findings) {
|
||||
if (isMatchedByAny(f.id, suppressions)) {
|
||||
suppressed.push(f);
|
||||
} else {
|
||||
active.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
return { active, suppressed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a finding ID matches any suppression pattern.
|
||||
* @param {string} id - Finding ID (e.g. CA-CML-001)
|
||||
* @param {Array<{ pattern: string }>} suppressions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMatchedByAny(id, suppressions) {
|
||||
for (const s of suppressions) {
|
||||
if (matchPattern(id, s.pattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a finding ID against a suppression pattern.
|
||||
* Supports exact match and glob-style CA-XXX-* patterns.
|
||||
* @param {string} id - e.g. "CA-CML-001"
|
||||
* @param {string} pattern - e.g. "CA-CML-001" or "CA-CML-*"
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function matchPattern(id, pattern) {
|
||||
// Exact match
|
||||
if (id === pattern) return true;
|
||||
|
||||
// Glob: CA-XXX-* matches any CA-XXX-NNN
|
||||
if (pattern.endsWith('-*')) {
|
||||
const prefix = pattern.slice(0, -1); // "CA-XXX-"
|
||||
return id.startsWith(prefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a human-readable suppression summary line.
|
||||
* @param {object[]} suppressed - Array of suppressed findings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSuppressionSummary(suppressed) {
|
||||
if (!suppressed || suppressed.length === 0) {
|
||||
return '0 findings suppressed';
|
||||
}
|
||||
|
||||
// Group by scanner prefix pattern
|
||||
const groups = new Map();
|
||||
for (const f of suppressed) {
|
||||
// Extract prefix: CA-CML-001 → CA-CML
|
||||
const prefix = f.id.replace(/-\d+$/, '');
|
||||
groups.set(prefix, (groups.get(prefix) || 0) + 1);
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
for (const [prefix, count] of groups) {
|
||||
parts.push(`${count} \u00d7 ${prefix}-*`);
|
||||
}
|
||||
|
||||
return `${suppressed.length} finding(s) suppressed (${parts.join(', ')})`;
|
||||
}
|
||||
182
plugins/config-audit/scanners/lib/yaml-parser.mjs
Normal file
182
plugins/config-audit/scanners/lib/yaml-parser.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Regex-based YAML frontmatter parser for Claude Code .md files.
|
||||
* Handles YAML frontmatter (--- delimited) and basic YAML parsing.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content.
|
||||
* @param {string} content
|
||||
* @returns {{ frontmatter: object | null, body: string, bodyStartLine: number }}
|
||||
*/
|
||||
export function parseFrontmatter(content) {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)(?:\r?\n)?---(?:\r?\n|$)/);
|
||||
if (!match) {
|
||||
return { frontmatter: null, body: content, bodyStartLine: 1 };
|
||||
}
|
||||
|
||||
const raw = match[1];
|
||||
const bodyStartLine = raw.split('\n').length + 3; // 2 for --- lines + 1-based
|
||||
const body = content.slice(match[0].length);
|
||||
const frontmatter = parseSimpleYaml(raw);
|
||||
|
||||
return { frontmatter, body, bodyStartLine };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse simple YAML key-value pairs (no nesting beyond arrays).
|
||||
* @param {string} yaml
|
||||
* @returns {object}
|
||||
*/
|
||||
export function parseSimpleYaml(yaml) {
|
||||
const result = {};
|
||||
const lines = yaml.split('\n');
|
||||
let currentKey = null;
|
||||
let multiLineValue = '';
|
||||
let inMultiLine = false;
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
if (line.trim().startsWith('#') || line.trim() === '') {
|
||||
if (inMultiLine) multiLineValue += '\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-value pair
|
||||
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
|
||||
if (kvMatch && !inMultiLine) {
|
||||
if (currentKey && multiLineValue) {
|
||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||
}
|
||||
|
||||
currentKey = kvMatch[1];
|
||||
const value = kvMatch[2].trim();
|
||||
|
||||
if (value === '|' || value === '>') {
|
||||
inMultiLine = true;
|
||||
multiLineValue = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
result[normalizeKey(currentKey)] = parseValue(value);
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi-line continuation
|
||||
if (inMultiLine) {
|
||||
if (line.match(/^\s+/)) {
|
||||
multiLineValue += (multiLineValue ? '\n' : '') + line.trim();
|
||||
} else {
|
||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||
inMultiLine = false;
|
||||
multiLineValue = '';
|
||||
// Re-process this line as a new key
|
||||
const reMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
|
||||
if (reMatch) {
|
||||
currentKey = reMatch[1];
|
||||
result[normalizeKey(currentKey)] = parseValue(reMatch[2].trim());
|
||||
currentKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining multi-line
|
||||
if (inMultiLine && currentKey) {
|
||||
result[normalizeKey(currentKey)] = multiLineValue.trim();
|
||||
}
|
||||
|
||||
// Normalize arrays for known list fields
|
||||
for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) {
|
||||
if (typeof result[field] === 'string') {
|
||||
result[field] = result[field].split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a YAML value string.
|
||||
*/
|
||||
function parseValue(str) {
|
||||
if (str === '' || str === '~' || str === 'null') return null;
|
||||
if (str === 'true') return true;
|
||||
if (str === 'false') return false;
|
||||
if (/^\d+$/.test(str)) return parseInt(str, 10);
|
||||
if (/^\d+\.\d+$/.test(str)) return parseFloat(str);
|
||||
|
||||
// Inline array: [a, b, c]
|
||||
if (str.startsWith('[') && str.endsWith(']')) {
|
||||
return str.slice(1, -1).split(',').map(s => {
|
||||
const v = s.trim();
|
||||
return v.replace(/^["']|["']$/g, '');
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
// Quoted string
|
||||
if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
|
||||
return str.slice(1, -1);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize key: hyphens to underscores.
|
||||
*/
|
||||
function normalizeKey(key) {
|
||||
return key.replace(/-/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON file content. Returns null on error.
|
||||
* @param {string} content
|
||||
* @returns {object | null}
|
||||
*/
|
||||
export function parseJson(content) {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find @import references in CLAUDE.md content.
|
||||
* @param {string} content
|
||||
* @returns {{ path: string, line: number }[]}
|
||||
*/
|
||||
export function findImports(content) {
|
||||
const imports = [];
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^@(.+)$/);
|
||||
if (match) {
|
||||
imports.push({ path: match[1].trim(), line: i + 1 });
|
||||
}
|
||||
}
|
||||
return imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract markdown sections (## headings) from content.
|
||||
* @param {string} content
|
||||
* @returns {{ heading: string, level: number, line: number }[]}
|
||||
*/
|
||||
export function extractSections(content) {
|
||||
const sections = [];
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^(#{1,6})\s+(.+)/);
|
||||
if (match) {
|
||||
sections.push({
|
||||
heading: match[2].trim(),
|
||||
level: match[1].length,
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue