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
209
plugins/config-audit/scanners/claude-md-linter.mjs
Normal file
209
plugins/config-audit/scanners/claude-md-linter.mjs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* CML Scanner — CLAUDE.md Linter
|
||||
* Validates structure, sections, length, @imports, frontmatter, and HTML comments.
|
||||
* Finding IDs: CA-CML-NNN
|
||||
*/
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
|
||||
import { lineCount, truncate } from './lib/string-utils.mjs';
|
||||
|
||||
const SCANNER = 'CML';
|
||||
const MAX_RECOMMENDED_LINES = 200;
|
||||
const MAX_ABSOLUTE_LINES = 500;
|
||||
|
||||
/** Recommended sections for a project CLAUDE.md */
|
||||
const RECOMMENDED_SECTIONS = [
|
||||
{ pattern: /project|overview|description|what/i, label: 'Project overview' },
|
||||
{ pattern: /command|workflow|how to|getting started|usage/i, label: 'Commands/Workflows' },
|
||||
{ pattern: /architect|structure|directory|layout/i, label: 'Architecture' },
|
||||
{ pattern: /convention|pattern|rule|style/i, label: 'Conventions/Patterns' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Scan all CLAUDE.md files discovered.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const claudeFiles = discovery.files.filter(f => f.type === 'claude-md');
|
||||
|
||||
if (claudeFiles.length === 0) {
|
||||
return scannerResult(SCANNER, 'ok', [
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'No CLAUDE.md found',
|
||||
description: 'No CLAUDE.md files were discovered. This is the primary configuration surface for Claude Code.',
|
||||
recommendation: 'Run `/init` to create a starter CLAUDE.md, or create one manually.',
|
||||
autoFixable: false,
|
||||
}),
|
||||
], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
for (const file of claudeFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
filesScanned++;
|
||||
|
||||
const lines = lineCount(content);
|
||||
const { frontmatter, body, bodyStartLine } = parseFrontmatter(content);
|
||||
const sections = extractSections(body);
|
||||
const imports = findImports(content);
|
||||
|
||||
// --- Length checks ---
|
||||
if (lines > MAX_ABSOLUTE_LINES) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'CLAUDE.md exceeds 500 lines',
|
||||
description: `${file.relPath} has ${lines} lines. Files over 500 lines significantly reduce Claude's adherence to instructions.`,
|
||||
file: file.absPath,
|
||||
evidence: `${lines} lines`,
|
||||
recommendation: 'Split into @imports and .claude/rules/ files. Keep CLAUDE.md under 200 lines.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
} else if (lines > MAX_RECOMMENDED_LINES) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds recommended 200 lines',
|
||||
description: `${file.relPath} has ${lines} lines. Best practice is under 200 lines for optimal adherence.`,
|
||||
file: file.absPath,
|
||||
evidence: `${lines} lines`,
|
||||
recommendation: 'Consider using @imports or .claude/rules/ for detailed content.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Empty file ---
|
||||
if (lines < 3) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md is nearly empty',
|
||||
description: `${file.relPath} has only ${lines} lines.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add project overview, commands/workflows, and conventions.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue; // Skip further checks for empty files
|
||||
}
|
||||
|
||||
// --- Section checks (only for project/user scope) ---
|
||||
if (file.scope === 'project' || file.scope === 'user') {
|
||||
const sectionHeadings = sections.map(s => s.heading);
|
||||
const missingSections = [];
|
||||
|
||||
for (const rec of RECOMMENDED_SECTIONS) {
|
||||
const found = sectionHeadings.some(h => rec.pattern.test(h));
|
||||
if (!found) {
|
||||
missingSections.push(rec.label);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingSections.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Missing recommended sections',
|
||||
description: `${file.relPath} is missing: ${missingSections.join(', ')}`,
|
||||
file: file.absPath,
|
||||
evidence: `Present sections: ${sectionHeadings.slice(0, 5).join(', ') || '(none)'}`,
|
||||
recommendation: `Add sections for: ${missingSections.join(', ')}`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- No headings at all ---
|
||||
if (sections.length === 0 && lines > 10) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md has no markdown headings',
|
||||
description: `${file.relPath} has ${lines} lines but no ## headings. Structured content with headers improves Claude's ability to find and follow instructions.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add markdown headings (##) to organize content into scannable sections.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- @import checks ---
|
||||
for (const imp of imports) {
|
||||
// Check for @imports referencing non-existent files
|
||||
// (Full resolution is in import-resolver scanner, here we just flag obvious issues)
|
||||
if (imp.path.includes('..') && imp.path.split('..').length > 3) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: '@import with deep relative path',
|
||||
description: `${file.relPath}:${imp.line} imports "${truncate(imp.path, 60)}" with multiple parent traversals.`,
|
||||
file: file.absPath,
|
||||
line: imp.line,
|
||||
evidence: `@${imp.path}`,
|
||||
recommendation: 'Consider using absolute paths or moving the imported file closer.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTML comment info ---
|
||||
const htmlComments = (content.match(/<!--[\s\S]*?-->/g) || []).length;
|
||||
if (htmlComments > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'Uses HTML comments',
|
||||
description: `${file.relPath} uses ${htmlComments} HTML comment(s). These are stripped before injection, saving tokens.`,
|
||||
file: file.absPath,
|
||||
evidence: `${htmlComments} HTML comment(s)`,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Duplicate content detection (simple: repeated lines) ---
|
||||
const lineArr = content.split('\n');
|
||||
const lineCounts = new Map();
|
||||
for (const l of lineArr) {
|
||||
const trimmed = l.trim();
|
||||
if (trimmed.length > 20 && !trimmed.startsWith('#') && !trimmed.startsWith('|') && !trimmed.startsWith('-')) {
|
||||
lineCounts.set(trimmed, (lineCounts.get(trimmed) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const duplicates = [...lineCounts.entries()].filter(([, count]) => count >= 3);
|
||||
if (duplicates.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Repeated content detected',
|
||||
description: `${file.relPath} has ${duplicates.length} line(s) repeated 3+ times.`,
|
||||
file: file.absPath,
|
||||
evidence: truncate(duplicates[0][0], 80),
|
||||
recommendation: 'Extract repeated content into a shared @import or rules file.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- TODO/FIXME markers ---
|
||||
const todos = lineArr.filter(l => /\bTODO\b|\bFIXME\b|\bHACK\b/i.test(l));
|
||||
if (todos.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'Contains TODO/FIXME markers',
|
||||
description: `${file.relPath} has ${todos.length} TODO/FIXME/HACK marker(s).`,
|
||||
file: file.absPath,
|
||||
evidence: truncate(todos[0].trim(), 80),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
238
plugins/config-audit/scanners/conflict-detector.mjs
Normal file
238
plugins/config-audit/scanners/conflict-detector.mjs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* CNF Scanner — Conflict Detector
|
||||
* Detects conflicts between config files at different hierarchy levels:
|
||||
* settings key conflicts, permission contradictions, hook duplicates.
|
||||
* Finding IDs: CA-CNF-NNN
|
||||
*/
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { truncate } from './lib/string-utils.mjs';
|
||||
|
||||
const SCANNER = 'CNF';
|
||||
|
||||
// Keys checked separately or not meaningful to compare
|
||||
const SKIP_KEYS = new Set(['$schema', 'hooks', 'permissions']);
|
||||
|
||||
/**
|
||||
* Extract the tool name prefix from a permission rule.
|
||||
* e.g., "Bash(npm run *)" → "Bash", "Read(src/**)" → "Read"
|
||||
* @param {string} rule
|
||||
* @returns {{ tool: string, pattern: string }}
|
||||
*/
|
||||
function parsePermissionRule(rule) {
|
||||
const match = rule.match(/^(\w+)\((.+)\)$/);
|
||||
if (match) return { tool: match[1], pattern: match[2] };
|
||||
return { tool: rule, pattern: '*' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an object's top-level keys into a simple key→value map.
|
||||
* Only first level — we compare top-level settings, not nested.
|
||||
* @param {object} obj
|
||||
* @returns {Map<string, string>} key → JSON-stringified value
|
||||
*/
|
||||
function flattenTopLevel(obj) {
|
||||
const map = new Map();
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (!SKIP_KEYS.has(key)) {
|
||||
map.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect hooks from a parsed settings or hooks.json object.
|
||||
* @param {object} parsed
|
||||
* @returns {{ event: string, matcher: string }[]}
|
||||
*/
|
||||
function collectHooks(parsed) {
|
||||
const hooks = parsed.hooks || parsed;
|
||||
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return [];
|
||||
|
||||
const result = [];
|
||||
for (const [event, handlers] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(handlers)) continue;
|
||||
for (const handler of handlers) {
|
||||
const matcher = typeof handler.matcher === 'string' ? handler.matcher : '*';
|
||||
result.push({ event, matcher });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for conflicts across configuration scopes.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const findings = [];
|
||||
|
||||
// Collect settings files
|
||||
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
|
||||
// Collect hooks files
|
||||
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
|
||||
|
||||
const totalFiles = settingsFiles.length + hooksFiles.length;
|
||||
|
||||
// Need at least 2 files to detect conflicts
|
||||
if (settingsFiles.length < 2 && (settingsFiles.length + hooksFiles.length) < 2) {
|
||||
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
// --- Settings key conflicts ---
|
||||
const settingsByScope = []; // [{ scope, file, keys: Map<key, jsonValue> }]
|
||||
|
||||
for (const file of settingsFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
const parsed = parseJson(content);
|
||||
if (!parsed) continue;
|
||||
settingsByScope.push({
|
||||
scope: file.scope,
|
||||
file: file.relPath,
|
||||
absPath: file.absPath,
|
||||
keys: flattenTopLevel(parsed),
|
||||
raw: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
// Compare keys across scopes
|
||||
if (settingsByScope.length >= 2) {
|
||||
const allKeys = new Set();
|
||||
for (const s of settingsByScope) {
|
||||
for (const key of s.keys.keys()) allKeys.add(key);
|
||||
}
|
||||
|
||||
for (const key of allKeys) {
|
||||
const scopesWithKey = settingsByScope.filter(s => s.keys.has(key));
|
||||
if (scopesWithKey.length < 2) continue;
|
||||
|
||||
// Check if values differ
|
||||
const values = new Set(scopesWithKey.map(s => s.keys.get(key)));
|
||||
if (values.size > 1) {
|
||||
const details = scopesWithKey
|
||||
.map(s => `${s.scope} (${s.file}): ${truncate(s.keys.get(key), 40)}`)
|
||||
.join('; ');
|
||||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: `Settings key conflict: "${key}"`,
|
||||
description: `Key "${key}" has different values across scopes. ${details}`,
|
||||
file: scopesWithKey[0].absPath,
|
||||
evidence: details,
|
||||
recommendation: `Verify the "${key}" value is intentionally different across scopes. The most specific scope wins (local > project > user).`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Permission conflicts ---
|
||||
for (let i = 0; i < settingsByScope.length; i++) {
|
||||
for (let j = i + 1; j < settingsByScope.length; j++) {
|
||||
const a = settingsByScope[i];
|
||||
const b = settingsByScope[j];
|
||||
|
||||
const aPerms = a.raw.permissions || {};
|
||||
const bPerms = b.raw.permissions || {};
|
||||
|
||||
const aAllow = Array.isArray(aPerms.allow) ? aPerms.allow : [];
|
||||
const aDeny = Array.isArray(aPerms.deny) ? aPerms.deny : [];
|
||||
const bAllow = Array.isArray(bPerms.allow) ? bPerms.allow : [];
|
||||
const bDeny = Array.isArray(bPerms.deny) ? bPerms.deny : [];
|
||||
|
||||
// Check: allow in A, deny in B (and vice versa)
|
||||
for (const allowRule of aAllow) {
|
||||
const { tool: aTool, pattern: aPattern } = parsePermissionRule(allowRule);
|
||||
for (const denyRule of bDeny) {
|
||||
const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule);
|
||||
if (aTool === dTool && (aPattern === dPattern || aPattern === '*' || dPattern === '*')) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Permission allow/deny conflict',
|
||||
description: `"${allowRule}" is allowed in ${a.scope} (${a.file}) but denied in ${b.scope} (${b.file}).`,
|
||||
file: a.absPath,
|
||||
evidence: `allow: "${allowRule}" (${a.scope}) vs deny: "${denyRule}" (${b.scope})`,
|
||||
recommendation: 'Resolve the conflict. Deny always wins, but the conflicting allow rule is misleading.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse: allow in B, deny in A
|
||||
for (const allowRule of bAllow) {
|
||||
const { tool: bTool, pattern: bPattern } = parsePermissionRule(allowRule);
|
||||
for (const denyRule of aDeny) {
|
||||
const { tool: dTool, pattern: dPattern } = parsePermissionRule(denyRule);
|
||||
if (bTool === dTool && (bPattern === dPattern || bPattern === '*' || dPattern === '*')) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Permission allow/deny conflict',
|
||||
description: `"${allowRule}" is allowed in ${b.scope} (${b.file}) but denied in ${a.scope} (${a.file}).`,
|
||||
file: b.absPath,
|
||||
evidence: `allow: "${allowRule}" (${b.scope}) vs deny: "${denyRule}" (${a.scope})`,
|
||||
recommendation: 'Resolve the conflict. Deny always wins, but the conflicting allow rule is misleading.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hook duplicates (across settings + hooks.json files) ---
|
||||
const hookSources = []; // [{ event, matcher, source }]
|
||||
|
||||
for (const s of settingsByScope) {
|
||||
if (s.raw.hooks) {
|
||||
for (const h of collectHooks(s.raw)) {
|
||||
hookSources.push({ ...h, source: `${s.scope}:${s.file}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of hooksFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
const parsed = parseJson(content);
|
||||
if (!parsed) continue;
|
||||
const hookData = parsed.hooks || parsed;
|
||||
for (const h of collectHooks(hookData)) {
|
||||
hookSources.push({ ...h, source: `hooks:${file.relPath}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Group by event:matcher
|
||||
const hookGroups = new Map();
|
||||
for (const h of hookSources) {
|
||||
const key = `${h.event}:${h.matcher}`;
|
||||
if (!hookGroups.has(key)) hookGroups.set(key, []);
|
||||
hookGroups.get(key).push(h.source);
|
||||
}
|
||||
|
||||
for (const [key, sources] of hookGroups) {
|
||||
// Only flag duplicates from DIFFERENT sources
|
||||
const uniqueSources = [...new Set(sources)];
|
||||
if (uniqueSources.length >= 2) {
|
||||
const [event, matcher] = key.split(':');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Duplicate hook definition',
|
||||
description: `Hook "${event}" with matcher "${matcher}" is defined in ${uniqueSources.length} sources.`,
|
||||
evidence: uniqueSources.join(', '),
|
||||
recommendation: 'Consolidate hook definitions to avoid unexpected execution order.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, totalFiles, Date.now() - start);
|
||||
}
|
||||
130
plugins/config-audit/scanners/drift-cli.mjs
Normal file
130
plugins/config-audit/scanners/drift-cli.mjs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Config-Audit Drift CLI
|
||||
* Compare current configuration against a saved baseline.
|
||||
* Usage:
|
||||
* node drift-cli.mjs <path> --save [--name my-baseline]
|
||||
* node drift-cli.mjs <path> [--baseline my-baseline] [--json]
|
||||
* node drift-cli.mjs --list
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
|
||||
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
let baselineName = 'default';
|
||||
let save = false;
|
||||
let list = false;
|
||||
let jsonMode = false;
|
||||
let includeGlobal = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--save') {
|
||||
save = true;
|
||||
} else if (args[i] === '--name' && args[i + 1]) {
|
||||
baselineName = args[++i];
|
||||
} else if (args[i] === '--baseline' && args[i + 1]) {
|
||||
baselineName = args[++i];
|
||||
} else if (args[i] === '--list') {
|
||||
list = true;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--global') {
|
||||
includeGlobal = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
// --- List mode ---
|
||||
if (list) {
|
||||
const result = await listBaselines();
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
} else {
|
||||
if (result.baselines.length === 0) {
|
||||
process.stderr.write('No baselines saved.\n');
|
||||
process.stderr.write('Save one with: node drift-cli.mjs <path> --save\n');
|
||||
} else {
|
||||
process.stderr.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
process.stderr.write(' Saved Baselines\n');
|
||||
process.stderr.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n');
|
||||
for (const b of result.baselines) {
|
||||
process.stderr.write(` ${b.name.padEnd(20)} ${b.findingCount} findings ${b.savedAt}\n`);
|
||||
}
|
||||
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- Save mode ---
|
||||
if (save) {
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(`Config-Audit Drift CLI v2.1.0\n`);
|
||||
process.stderr.write(`Saving baseline "${baselineName}" for ${resolve(targetPath)}\n\n`);
|
||||
}
|
||||
|
||||
const envelope = await runAllScanners(targetPath, { includeGlobal });
|
||||
const result = await saveBaseline(envelope, baselineName);
|
||||
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify({ saved: true, name: result.name, path: result.path }, null, 2) + '\n');
|
||||
} else {
|
||||
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
|
||||
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- Drift mode (default) ---
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(`Config-Audit Drift CLI v2.1.0\n`);
|
||||
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
|
||||
process.stderr.write(`Baseline: ${baselineName}\n\n`);
|
||||
}
|
||||
|
||||
// Load baseline
|
||||
const baseline = await loadBaseline(baselineName);
|
||||
if (!baseline) {
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify({ error: `Baseline "${baselineName}" not found. Save one with --save.` }, null, 2) + '\n');
|
||||
} else {
|
||||
process.stderr.write(`Baseline "${baselineName}" not found.\n`);
|
||||
process.stderr.write(`Save one first: node drift-cli.mjs <path> --save\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run current scan
|
||||
const current = await runAllScanners(targetPath, { includeGlobal });
|
||||
|
||||
// Diff
|
||||
const diff = diffEnvelopes(baseline, current);
|
||||
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
|
||||
} else {
|
||||
const report = formatDiffReport(diff);
|
||||
process.stderr.write('\n' + report + '\n');
|
||||
}
|
||||
|
||||
// Exit code: 0=stable/improving, 1=degrading
|
||||
if (diff.summary.trend === 'degrading') process.exit(1);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Only run CLI if invoked directly
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
410
plugins/config-audit/scanners/feature-gap-scanner.mjs
Normal file
410
plugins/config-audit/scanners/feature-gap-scanner.mjs
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
/**
|
||||
* GAP Scanner — Feature Gap Scanner
|
||||
* Compares actual configuration against complete Claude Code feature register.
|
||||
* 25 gap dimensions across 4 tiers. Always runs with includeGlobal: true.
|
||||
* Finding IDs: CA-GAP-NNN
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
|
||||
const SCANNER = 'GAP';
|
||||
|
||||
/**
|
||||
* @typedef {object} GapCheck
|
||||
* @property {string} id - Short identifier (t1_1 through t4_5)
|
||||
* @property {string} tier - t1|t2|t3|t4
|
||||
* @property {string} title - Human-readable title
|
||||
* @property {string} recommendation - What to do
|
||||
* @property {(ctx: CheckContext) => Promise<boolean>} check - Returns true if feature IS present
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CheckContext
|
||||
* @property {import('./lib/file-discovery.mjs').ConfigFile[]} files
|
||||
* @property {string} targetPath
|
||||
* @property {Map<string, object>} parsedSettings - scope → parsed JSON
|
||||
* @property {Map<string, string>} fileContents - absPath → content
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a file belongs to the target project (vs global ~/.claude/).
|
||||
* Needed because scope classification can be 'plugin' when running inside ~/.claude/plugins/.
|
||||
* @param {CheckContext} ctx
|
||||
* @param {import('./lib/file-discovery.mjs').ConfigFile} f
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isTargetLocal(ctx, f) {
|
||||
return f.absPath.startsWith(ctx.targetPath);
|
||||
}
|
||||
|
||||
const TIER_SEVERITY = {
|
||||
t1: SEVERITY.medium,
|
||||
t2: SEVERITY.low,
|
||||
t3: SEVERITY.info,
|
||||
t4: SEVERITY.info,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lazily read and cache file content.
|
||||
* @param {CheckContext} ctx
|
||||
* @param {string} absPath
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function getContent(ctx, absPath) {
|
||||
if (ctx.fileContents.has(absPath)) return ctx.fileContents.get(absPath);
|
||||
const content = await readTextFile(absPath);
|
||||
ctx.fileContents.set(absPath, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any settings file has a specific key.
|
||||
* @param {CheckContext} ctx
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function anySettingsHas(ctx, key) {
|
||||
for (const parsed of ctx.parsedSettings.values()) {
|
||||
if (parsed && key in parsed) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from any settings file (first match).
|
||||
* @param {CheckContext} ctx
|
||||
* @param {string} key
|
||||
* @returns {*}
|
||||
*/
|
||||
function getSettingsValue(ctx, key) {
|
||||
for (const parsed of ctx.parsedSettings.values()) {
|
||||
if (parsed && key in parsed) return parsed[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** @type {GapCheck[]} */
|
||||
const GAP_CHECKS = [
|
||||
// --- Tier 1: Foundation ---
|
||||
{
|
||||
id: 't1_1', tier: 't1',
|
||||
title: 'No CLAUDE.md file',
|
||||
recommendation: 'Create a CLAUDE.md at the project root with project-specific instructions, commands, and architecture.',
|
||||
check: async (ctx) => ctx.files.some(f => f.type === 'claude-md' && isTargetLocal(ctx, f)),
|
||||
},
|
||||
{
|
||||
id: 't1_2', tier: 't1',
|
||||
title: 'No permissions configured',
|
||||
recommendation: 'Add permissions.allow and permissions.deny in .claude/settings.json to control tool access.',
|
||||
check: async (ctx) => {
|
||||
for (const parsed of ctx.parsedSettings.values()) {
|
||||
if (parsed?.permissions && (parsed.permissions.allow?.length > 0 || parsed.permissions.deny?.length > 0)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't1_3', tier: 't1',
|
||||
title: 'No hooks configured',
|
||||
recommendation: 'Add at least one hook (e.g., PreToolUse for security, Stop for session summaries). See knowledge/hook-events-reference.md.',
|
||||
check: async (ctx) => {
|
||||
if (ctx.files.some(f => f.type === 'hooks-json')) return true;
|
||||
for (const parsed of ctx.parsedSettings.values()) {
|
||||
if (parsed?.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
|
||||
return Object.keys(parsed.hooks).length > 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't1_4', tier: 't1',
|
||||
title: 'No custom skills or commands',
|
||||
recommendation: 'Create project-specific skills in .claude/skills/ or commands in .claude/commands/ to automate repetitive workflows.',
|
||||
check: async (ctx) => ctx.files.some(f => f.type === 'skill-md' || f.type === 'command-md'),
|
||||
},
|
||||
{
|
||||
id: 't1_5', tier: 't1',
|
||||
title: 'No MCP servers configured',
|
||||
recommendation: 'Add a .mcp.json at the project root to configure MCP servers for enhanced tool access.',
|
||||
check: async (ctx) => ctx.files.some(f => f.type === 'mcp-json'),
|
||||
},
|
||||
|
||||
// --- Tier 2: Configuration Depth ---
|
||||
{
|
||||
id: 't2_1', tier: 't2',
|
||||
title: 'Settings only at one scope',
|
||||
recommendation: 'Use all 3 settings scopes: ~/.claude/settings.json (user), .claude/settings.json (project), .claude/settings.local.json (local/personal).',
|
||||
check: async (ctx) => {
|
||||
const localSettings = ctx.files.filter(f => f.type === 'settings-json' && isTargetLocal(ctx, f));
|
||||
const hasGlobal = ctx.files.some(f => f.type === 'settings-json' && !isTargetLocal(ctx, f));
|
||||
return (localSettings.length >= 2) || (localSettings.length >= 1 && hasGlobal);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't2_2', tier: 't2',
|
||||
title: 'CLAUDE.md not modular',
|
||||
recommendation: 'Use @imports or .claude/rules/ to split large CLAUDE.md files into focused modules.',
|
||||
check: async (ctx) => {
|
||||
// Has rules files OR has @imports in any CLAUDE.md
|
||||
if (ctx.files.some(f => f.type === 'rule')) return true;
|
||||
for (const file of ctx.files.filter(f => f.type === 'claude-md')) {
|
||||
const content = await getContent(ctx, file.absPath);
|
||||
if (content && findImports(content).length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't2_3', tier: 't2',
|
||||
title: 'No path-scoped rules',
|
||||
recommendation: 'Create .claude/rules/*.md with paths: frontmatter to apply rules only to matching files.',
|
||||
check: async (ctx) => {
|
||||
for (const file of ctx.files.filter(f => f.type === 'rule')) {
|
||||
const content = await getContent(ctx, file.absPath);
|
||||
if (content) {
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
if (frontmatter && (frontmatter.paths || frontmatter.globs)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't2_4', tier: 't2',
|
||||
title: 'Auto-memory explicitly disabled',
|
||||
recommendation: 'Enable auto-memory by removing autoMemoryEnabled: false from settings.',
|
||||
check: async (ctx) => {
|
||||
// Present (gap) only if explicitly disabled
|
||||
const val = getSettingsValue(ctx, 'autoMemoryEnabled');
|
||||
return val !== false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't2_5', tier: 't2',
|
||||
title: 'Low hook diversity',
|
||||
recommendation: 'Use hooks across 3+ events (e.g., SessionStart, PreToolUse, Stop) for comprehensive automation.',
|
||||
check: async (ctx) => {
|
||||
const events = new Set();
|
||||
for (const parsed of ctx.parsedSettings.values()) {
|
||||
if (parsed?.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
|
||||
for (const event of Object.keys(parsed.hooks)) events.add(event);
|
||||
}
|
||||
}
|
||||
for (const file of ctx.files.filter(f => f.type === 'hooks-json')) {
|
||||
const content = await getContent(ctx, file.absPath);
|
||||
if (content) {
|
||||
const parsed = parseJson(content);
|
||||
const hookData = parsed?.hooks || parsed;
|
||||
if (hookData && typeof hookData === 'object' && !Array.isArray(hookData)) {
|
||||
for (const event of Object.keys(hookData)) events.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
return events.size >= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't2_6', tier: 't2',
|
||||
title: 'No custom subagents',
|
||||
recommendation: 'Create custom agents in .claude/agents/ or ~/.claude/agents/ with specialized tools and model selection.',
|
||||
check: async (ctx) => ctx.files.some(f => f.type === 'agent-md'),
|
||||
},
|
||||
{
|
||||
id: 't2_7', tier: 't2',
|
||||
title: 'No model configuration',
|
||||
recommendation: 'Set model preferences in settings.json (model, modelOverrides) for cost/quality optimization.',
|
||||
check: async (ctx) => anySettingsHas(ctx, 'model') || anySettingsHas(ctx, 'modelOverrides'),
|
||||
},
|
||||
|
||||
// --- Tier 3: Advanced Features ---
|
||||
{
|
||||
id: 't3_1', tier: 't3',
|
||||
title: 'No status line configured',
|
||||
recommendation: 'Configure statusLine in settings.json to show context window usage, cost, and model info.',
|
||||
check: async (ctx) => anySettingsHas(ctx, 'statusLine'),
|
||||
},
|
||||
{
|
||||
id: 't3_2', tier: 't3',
|
||||
title: 'No custom keybindings',
|
||||
recommendation: 'Create ~/.claude/keybindings.json to customize keyboard shortcuts (e.g., bind chat:newline to Shift+Enter).',
|
||||
check: async (ctx) => ctx.files.some(f => f.type === 'keybindings-json'),
|
||||
},
|
||||
{
|
||||
id: 't3_3', tier: 't3',
|
||||
title: 'Using default output style',
|
||||
recommendation: 'Try "Explanatory" or "Learning" output styles, or create custom styles in .claude/output-styles/.',
|
||||
check: async (ctx) => anySettingsHas(ctx, 'outputStyle'),
|
||||
},
|
||||
{
|
||||
id: 't3_4', tier: 't3',
|
||||
title: 'No worktree workflow',
|
||||
recommendation: 'Use --worktree for parallel feature development. Configure worktree.symlinkDirectories for node_modules.',
|
||||
check: async (ctx) => anySettingsHas(ctx, 'worktree'),
|
||||
},
|
||||
{
|
||||
id: 't3_5', tier: 't3',
|
||||
title: 'No advanced skill frontmatter',
|
||||
recommendation: 'Use disable-model-invocation, context:fork, or argument-hint in skill frontmatter for better control.',
|
||||
check: async (ctx) => {
|
||||
for (const file of ctx.files.filter(f => f.type === 'skill-md')) {
|
||||
const content = await getContent(ctx, file.absPath);
|
||||
if (content) {
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
if (frontmatter && (
|
||||
frontmatter.disable_model_invocation ||
|
||||
frontmatter.context === 'fork' ||
|
||||
frontmatter.argument_hint
|
||||
)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't3_6', tier: 't3',
|
||||
title: 'No subagent isolation',
|
||||
recommendation: 'Use isolation: worktree in agent frontmatter for safe parallel development.',
|
||||
check: async (ctx) => {
|
||||
for (const file of ctx.files.filter(f => f.type === 'agent-md')) {
|
||||
const content = await getContent(ctx, file.absPath);
|
||||
if (content) {
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
if (frontmatter && frontmatter.isolation === 'worktree') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't3_7', tier: 't3',
|
||||
title: 'No dynamic skill context',
|
||||
recommendation: 'Use !`command` syntax in skills to inject dynamic context (e.g., !`git branch --show-current`).',
|
||||
check: async (ctx) => {
|
||||
for (const file of ctx.files.filter(f => f.type === 'skill-md' || f.type === 'command-md')) {
|
||||
const content = await getContent(ctx, file.absPath);
|
||||
if (content && /!`[^`]+`/.test(content)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't3_8', tier: 't3',
|
||||
title: 'No autoMode classifier',
|
||||
recommendation: 'Configure autoMode in user/local settings with environment context and allow/deny rules.',
|
||||
check: async (ctx) => anySettingsHas(ctx, 'autoMode'),
|
||||
},
|
||||
|
||||
// --- Tier 4: Team/Enterprise ---
|
||||
{
|
||||
id: 't4_1', tier: 't4',
|
||||
title: 'No project .mcp.json in git',
|
||||
recommendation: 'Add .mcp.json to git so the team shares MCP server configuration.',
|
||||
check: async (ctx) => ctx.files.some(f => f.type === 'mcp-json' && isTargetLocal(ctx, f)),
|
||||
},
|
||||
{
|
||||
id: 't4_2', tier: 't4',
|
||||
title: 'No custom plugin',
|
||||
recommendation: 'Package reusable skills, agents, and hooks as a Claude Code plugin with .claude-plugin/plugin.json.',
|
||||
check: async (ctx) => ctx.files.some(f => f.type === 'plugin-json'),
|
||||
},
|
||||
{
|
||||
id: 't4_3', tier: 't4',
|
||||
title: 'Agent teams not enabled',
|
||||
recommendation: 'Enable agent teams with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 for parallel multi-agent workflows.',
|
||||
check: async (ctx) => {
|
||||
for (const parsed of ctx.parsedSettings.values()) {
|
||||
const env = parsed?.env;
|
||||
if (env && env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1') return true;
|
||||
}
|
||||
return !!process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 't4_4', tier: 't4',
|
||||
title: 'No managed settings',
|
||||
recommendation: 'Use managed-settings.json for organization-wide policy enforcement.',
|
||||
check: async (ctx) => ctx.files.some(f => f.scope === 'managed'),
|
||||
},
|
||||
{
|
||||
id: 't4_5', tier: 't4',
|
||||
title: 'No LSP plugins',
|
||||
recommendation: 'Add .lsp.json for real-time code intelligence from language servers.',
|
||||
check: async (ctx) => ctx.files.some(f => f.relPath.endsWith('.lsp.json')),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Scan for feature gaps against Claude Code feature register.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} sharedDiscovery - Used when provided with files; otherwise runs own discovery with includeGlobal
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, sharedDiscovery) {
|
||||
const start = Date.now();
|
||||
const findings = [];
|
||||
|
||||
// Use shared discovery if it has files (e.g. from full-machine mode), otherwise run own
|
||||
const discovery = (sharedDiscovery && sharedDiscovery.files && sharedDiscovery.files.length > 0)
|
||||
? sharedDiscovery
|
||||
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
|
||||
|
||||
// Parse all settings files upfront
|
||||
const parsedSettings = new Map();
|
||||
for (const file of discovery.files.filter(f => f.type === 'settings-json')) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (content) {
|
||||
const parsed = parseJson(content);
|
||||
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
files: discovery.files,
|
||||
targetPath: resolve(targetPath),
|
||||
parsedSettings,
|
||||
fileContents: new Map(),
|
||||
};
|
||||
|
||||
for (const gap of GAP_CHECKS) {
|
||||
const present = await gap.check(ctx);
|
||||
if (!present) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: TIER_SEVERITY[gap.tier],
|
||||
title: gap.title,
|
||||
description: `Feature gap: ${gap.title}. ${gap.recommendation}`,
|
||||
recommendation: gap.recommendation,
|
||||
category: gap.tier,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const filesScanned = discovery.files.length;
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group GAP findings into impact categories for opportunity-based display.
|
||||
* @param {object[]} findings - GAP scanner findings (each has .category = t1|t2|t3|t4)
|
||||
* @returns {{ highImpact: object[], mediumImpact: object[], explore: object[] }}
|
||||
*/
|
||||
export function opportunitySummary(findings) {
|
||||
const highImpact = [];
|
||||
const mediumImpact = [];
|
||||
const explore = [];
|
||||
|
||||
for (const f of findings) {
|
||||
if (f.category === 't1') highImpact.push(f);
|
||||
else if (f.category === 't2') mediumImpact.push(f);
|
||||
else explore.push(f);
|
||||
}
|
||||
|
||||
return { highImpact, mediumImpact, explore };
|
||||
}
|
||||
186
plugins/config-audit/scanners/fix-cli.mjs
Normal file
186
plugins/config-audit/scanners/fix-cli.mjs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Config-Audit Fix CLI
|
||||
* Standalone entry point for running fixes without the command.
|
||||
* Usage: node fix-cli.mjs <path> [--apply] [--global] [--json]
|
||||
* Dry-run by default — must pass --apply to write changes.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
|
||||
import { createBackup } from './lib/backup.mjs';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
let apply = false;
|
||||
let jsonMode = false;
|
||||
let includeGlobal = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--apply') {
|
||||
apply = true;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--global') {
|
||||
includeGlobal = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(targetPath);
|
||||
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(`Config-Audit Fix CLI v2.1.0\n`);
|
||||
process.stderr.write(`Target: ${resolvedPath}\n`);
|
||||
process.stderr.write(`Mode: ${apply ? 'APPLY' : 'DRY-RUN'}\n\n`);
|
||||
process.stderr.write(`Scanning...\n`);
|
||||
}
|
||||
|
||||
// 1. Run all scanners
|
||||
const envelope = await runAllScanners(targetPath, { includeGlobal });
|
||||
|
||||
// 2. Plan fixes
|
||||
const { fixes, skipped, manual } = planFixes(envelope);
|
||||
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(`\n`);
|
||||
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
||||
process.stderr.write(` Config-Audit Fix Plan\n`);
|
||||
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`);
|
||||
|
||||
if (fixes.length > 0) {
|
||||
process.stderr.write(` Auto-fixable (${fixes.length}):\n`);
|
||||
for (let i = 0; i < fixes.length; i++) {
|
||||
process.stderr.write(` ${i + 1}. [${fixes[i].findingId}] ${fixes[i].description}\n`);
|
||||
}
|
||||
} else {
|
||||
process.stderr.write(` No auto-fixable issues found.\n`);
|
||||
}
|
||||
|
||||
if (manual.length > 0) {
|
||||
process.stderr.write(`\n Manual (${manual.length}):\n`);
|
||||
for (let i = 0; i < manual.length; i++) {
|
||||
process.stderr.write(` ${fixes.length + i + 1}. [${manual[i].findingId}] ${manual[i].title}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
process.stderr.write(`\n Skipped (${skipped.length}): could not generate fix plan\n`);
|
||||
}
|
||||
|
||||
process.stderr.write(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
||||
}
|
||||
|
||||
// 3. Apply or dry-run
|
||||
let applied = [];
|
||||
let failed = [];
|
||||
let verified = [];
|
||||
let regressions = [];
|
||||
let backupId = null;
|
||||
|
||||
if (fixes.length === 0) {
|
||||
if (jsonMode) {
|
||||
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
// Create backup first
|
||||
const filesToBackup = [...new Set(fixes.filter(f => f.type !== 'file-rename').map(f => f.file))];
|
||||
const backup = createBackup(filesToBackup);
|
||||
backupId = backup.backupId;
|
||||
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(`\n Backup created: ${backup.backupPath}\n`);
|
||||
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
|
||||
}
|
||||
|
||||
const result = await applyFixes(fixes, { dryRun: false, backupDir: backup.backupPath });
|
||||
applied = result.applied;
|
||||
failed = result.failed;
|
||||
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(` Results: ${applied.length} applied, ${failed.length} failed\n`);
|
||||
if (failed.length > 0) {
|
||||
for (const f of failed) {
|
||||
process.stderr.write(` FAILED: [${f.findingId}] ${f.error}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Verify
|
||||
if (applied.length > 0) {
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(`\n Verifying...\n`);
|
||||
}
|
||||
|
||||
const verification = await verifyFixes(envelope, applied);
|
||||
verified = verification.verified;
|
||||
regressions = verification.regressions;
|
||||
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(` Verified: ${verified.length}/${applied.length}\n`);
|
||||
if (regressions.length > 0) {
|
||||
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
|
||||
}
|
||||
process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${backupId}\n`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dry-run mode
|
||||
const result = await applyFixes(fixes, { dryRun: true });
|
||||
applied = result.applied;
|
||||
|
||||
if (!jsonMode) {
|
||||
process.stderr.write(`\n Dry-run complete. Pass --apply to execute.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if (jsonMode) {
|
||||
const output = {
|
||||
planned: fixes.map(f => ({
|
||||
findingId: f.findingId,
|
||||
file: f.file,
|
||||
type: f.type,
|
||||
description: f.description,
|
||||
})),
|
||||
applied: applied.map(a => ({
|
||||
findingId: a.findingId,
|
||||
file: a.file,
|
||||
status: a.status,
|
||||
})),
|
||||
failed: failed.map(f => ({
|
||||
findingId: f.findingId,
|
||||
file: f.file,
|
||||
status: f.status,
|
||||
error: f.error,
|
||||
})),
|
||||
verified,
|
||||
regressions,
|
||||
manual: manual.map(m => ({
|
||||
findingId: m.findingId,
|
||||
title: m.title,
|
||||
recommendation: m.recommendation,
|
||||
})),
|
||||
backupId,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Only run CLI if invoked directly
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
666
plugins/config-audit/scanners/fix-engine.mjs
Normal file
666
plugins/config-audit/scanners/fix-engine.mjs
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
/**
|
||||
* Config-Audit Fix Engine
|
||||
* Deterministic fix engine: maps scanner findings to concrete file changes.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, rename, stat } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { createBackup } from './lib/backup.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
|
||||
/**
|
||||
* Fix type constants.
|
||||
*/
|
||||
const FIX_TYPES = {
|
||||
JSON_KEY_ADD: 'json-key-add',
|
||||
JSON_KEY_REMOVE: 'json-key-remove',
|
||||
JSON_KEY_TYPE_FIX: 'json-key-type-fix',
|
||||
JSON_RESTRUCTURE: 'json-restructure',
|
||||
FRONTMATTER_RENAME: 'frontmatter-rename',
|
||||
FILE_RENAME: 'file-rename',
|
||||
};
|
||||
|
||||
/** Valid effortLevel values for nearest-match */
|
||||
const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
|
||||
|
||||
/**
|
||||
* Plan fixes from a scanner envelope.
|
||||
* @param {object} envelope - Full scanner envelope from scan-orchestrator
|
||||
* @returns {{ fixes: object[], skipped: object[], manual: object[] }}
|
||||
*/
|
||||
export function planFixes(envelope) {
|
||||
const fixes = [];
|
||||
const skipped = [];
|
||||
const manual = [];
|
||||
|
||||
for (const scanner of envelope.scanners) {
|
||||
for (const finding of scanner.findings) {
|
||||
if (!finding.autoFixable) {
|
||||
manual.push({
|
||||
findingId: finding.id,
|
||||
title: finding.title,
|
||||
file: finding.file,
|
||||
recommendation: finding.recommendation,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fixPlan = createFixPlan(finding);
|
||||
if (fixPlan) {
|
||||
fixes.push(fixPlan);
|
||||
} else {
|
||||
skipped.push(finding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort fixes by severity weight (critical first)
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));
|
||||
|
||||
return { fixes, skipped, manual };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fix plan for a single finding.
|
||||
* @param {object} finding
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function createFixPlan(finding) {
|
||||
if (!finding.file) return null;
|
||||
|
||||
const base = {
|
||||
findingId: finding.id,
|
||||
file: finding.file,
|
||||
severity: finding.severity,
|
||||
description: '',
|
||||
before: null,
|
||||
after: null,
|
||||
type: null,
|
||||
};
|
||||
|
||||
const scanner = finding.scanner;
|
||||
const title = finding.title;
|
||||
|
||||
// --- SET scanner fixes ---
|
||||
if (scanner === 'SET') {
|
||||
if (title === 'Missing $schema reference') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_ADD,
|
||||
description: 'Add $schema reference for IDE autocomplete',
|
||||
before: '(no $schema key)',
|
||||
after: '"$schema": "https://json.schemastore.org/claude-code-settings.json"',
|
||||
key: '$schema',
|
||||
value: 'https://json.schemastore.org/claude-code-settings.json',
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Deprecated settings key') {
|
||||
const key = extractKeyFromEvidence(finding.evidence);
|
||||
if (!key) return null;
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_REMOVE,
|
||||
description: `Remove deprecated key "${key}"`,
|
||||
before: finding.evidence,
|
||||
after: '(key removed)',
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Type mismatch in settings') {
|
||||
const key = extractKeyFromEvidence(finding.evidence);
|
||||
if (!key) return null;
|
||||
const expectedType = extractExpectedType(finding.description);
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
||||
description: `Fix type of "${key}" to ${expectedType}`,
|
||||
before: finding.evidence,
|
||||
after: `(converted to ${expectedType})`,
|
||||
key,
|
||||
expectedType,
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Invalid effortLevel value') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
||||
description: 'Fix effortLevel to nearest valid value',
|
||||
before: finding.evidence,
|
||||
after: '(nearest valid effortLevel)',
|
||||
key: 'effortLevel',
|
||||
expectedType: 'effortLevel',
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Hooks configured as array instead of object') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_RESTRUCTURE,
|
||||
description: 'Convert hooks from array to object format',
|
||||
before: '"hooks": [...]',
|
||||
after: '"hooks": { ... }',
|
||||
restructureType: 'hooks-array-to-object',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- HKV scanner fixes ---
|
||||
if (scanner === 'HKV') {
|
||||
if (title === 'Matcher must be a string, not an object') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_RESTRUCTURE,
|
||||
description: 'Convert matcher from object to string',
|
||||
before: finding.evidence,
|
||||
after: '"matcher": "ToolName"',
|
||||
restructureType: 'matcher-object-to-string',
|
||||
event: extractEventFromDescription(finding.description),
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Hook timeout must be a number') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
||||
description: 'Convert timeout to number',
|
||||
before: finding.evidence,
|
||||
after: '(parsed to number)',
|
||||
key: 'timeout',
|
||||
expectedType: 'number',
|
||||
event: extractEventFromDescription(finding.description),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- RUL scanner fixes ---
|
||||
if (scanner === 'RUL') {
|
||||
if (title === 'Rule uses deprecated "globs" field') {
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.FRONTMATTER_RENAME,
|
||||
description: 'Rename "globs" to "paths" in frontmatter',
|
||||
before: 'globs:',
|
||||
after: 'paths:',
|
||||
oldField: 'globs',
|
||||
newField: 'paths',
|
||||
};
|
||||
}
|
||||
|
||||
if (title === 'Rule file is not .md') {
|
||||
const newPath = finding.file.replace(/\.[^.]+$/, '.md');
|
||||
return {
|
||||
...base,
|
||||
type: FIX_TYPES.FILE_RENAME,
|
||||
description: `Rename to .md extension`,
|
||||
before: finding.file,
|
||||
after: newPath,
|
||||
newPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply planned fixes to files.
|
||||
* @param {object[]} fixPlans - Array of fix plans from planFixes()
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.dryRun=false]
|
||||
* @param {string} [opts.backupDir] - Required if not dryRun
|
||||
* @returns {Promise<{ applied: object[], failed: object[] }>}
|
||||
*/
|
||||
export async function applyFixes(fixPlans, opts = {}) {
|
||||
const applied = [];
|
||||
const failed = [];
|
||||
|
||||
if (!opts.dryRun && !opts.backupDir) {
|
||||
throw new Error('backupDir is required when not in dryRun mode');
|
||||
}
|
||||
|
||||
for (const plan of fixPlans) {
|
||||
if (opts.dryRun) {
|
||||
applied.push({
|
||||
findingId: plan.findingId,
|
||||
file: plan.file,
|
||||
status: 'dry-run',
|
||||
type: plan.type,
|
||||
description: plan.description,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await applyFix(plan);
|
||||
applied.push({
|
||||
findingId: plan.findingId,
|
||||
file: plan.file,
|
||||
status: 'applied',
|
||||
type: plan.type,
|
||||
description: plan.description,
|
||||
});
|
||||
} catch (err) {
|
||||
failed.push({
|
||||
findingId: plan.findingId,
|
||||
file: plan.file,
|
||||
status: 'failed',
|
||||
error: err.message,
|
||||
type: plan.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { applied, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single fix.
|
||||
* @param {object} plan
|
||||
*/
|
||||
async function applyFix(plan) {
|
||||
switch (plan.type) {
|
||||
case FIX_TYPES.JSON_KEY_ADD:
|
||||
await applyJsonKeyAdd(plan);
|
||||
break;
|
||||
case FIX_TYPES.JSON_KEY_REMOVE:
|
||||
await applyJsonKeyRemove(plan);
|
||||
break;
|
||||
case FIX_TYPES.JSON_KEY_TYPE_FIX:
|
||||
await applyJsonKeyTypeFix(plan);
|
||||
break;
|
||||
case FIX_TYPES.JSON_RESTRUCTURE:
|
||||
await applyJsonRestructure(plan);
|
||||
break;
|
||||
case FIX_TYPES.FRONTMATTER_RENAME:
|
||||
await applyFrontmatterRename(plan);
|
||||
break;
|
||||
case FIX_TYPES.FILE_RENAME:
|
||||
await applyFileRename(plan);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown fix type: ${plan.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a key to a JSON file (as first key for $schema).
|
||||
*/
|
||||
async function applyJsonKeyAdd(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) throw new Error('Invalid JSON');
|
||||
|
||||
// For $schema, insert as first key
|
||||
if (plan.key === '$schema') {
|
||||
const newObj = { $schema: plan.value, ...parsed };
|
||||
await writeJsonFile(plan.file, newObj);
|
||||
} else {
|
||||
parsed[plan.key] = plan.value;
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a key from a JSON file.
|
||||
*/
|
||||
async function applyJsonKeyRemove(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) throw new Error('Invalid JSON');
|
||||
|
||||
delete parsed[plan.key];
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix the type of a JSON key value.
|
||||
*/
|
||||
async function applyJsonKeyTypeFix(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) throw new Error('Invalid JSON');
|
||||
|
||||
// Handle nested hook timeout fixes
|
||||
if (plan.key === 'timeout' && plan.event) {
|
||||
fixTimeoutInHooks(parsed, plan.event);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle effortLevel special case
|
||||
if (plan.key === 'effortLevel' && plan.expectedType === 'effortLevel') {
|
||||
parsed.effortLevel = findNearestEffortLevel(parsed.effortLevel);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic type conversion
|
||||
if (parsed[plan.key] !== undefined) {
|
||||
parsed[plan.key] = convertType(parsed[plan.key], plan.expectedType);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restructure JSON (hooks array→object, matcher object→string).
|
||||
*/
|
||||
async function applyJsonRestructure(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) throw new Error('Invalid JSON');
|
||||
|
||||
if (plan.restructureType === 'hooks-array-to-object') {
|
||||
restructureHooksArrayToObject(parsed);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.restructureType === 'matcher-object-to-string') {
|
||||
restructureMatcherObjectToString(parsed, plan.event);
|
||||
await writeJsonFile(plan.file, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown restructure type: ${plan.restructureType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a frontmatter field in a markdown file.
|
||||
*/
|
||||
async function applyFrontmatterRename(plan) {
|
||||
const content = await readFile(plan.file, 'utf-8');
|
||||
|
||||
// Replace the field name in the frontmatter section only
|
||||
const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
|
||||
if (!fmMatch) throw new Error('No frontmatter found');
|
||||
|
||||
const before = fmMatch[2];
|
||||
const regex = new RegExp(`^(${plan.oldField})(\\s*:)`, 'gm');
|
||||
const after = before.replace(regex, `${plan.newField}$2`);
|
||||
|
||||
if (before === after) throw new Error(`Field "${plan.oldField}" not found in frontmatter`);
|
||||
|
||||
const newContent = fmMatch[1] + after + fmMatch[3] + content.slice(fmMatch[0].length);
|
||||
await writeFile(plan.file, newContent, 'utf-8');
|
||||
|
||||
// Validate frontmatter still parses
|
||||
const { frontmatter } = parseFrontmatter(newContent);
|
||||
if (!frontmatter) throw new Error('Frontmatter parse failed after rename');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file (change extension to .md).
|
||||
*/
|
||||
async function applyFileRename(plan) {
|
||||
try {
|
||||
await stat(plan.file);
|
||||
} catch {
|
||||
throw new Error(`Source file not found: ${plan.file}`);
|
||||
}
|
||||
|
||||
// Check target doesn't already exist
|
||||
try {
|
||||
await stat(plan.newPath);
|
||||
throw new Error(`Target already exists: ${plan.newPath}`);
|
||||
} catch (err) {
|
||||
if (err.message.startsWith('Target already exists')) throw err;
|
||||
// File doesn't exist — good
|
||||
}
|
||||
|
||||
await rename(plan.file, plan.newPath);
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
/**
|
||||
* Write a JSON object to a file with 2-space indent.
|
||||
*/
|
||||
async function writeJsonFile(filePath, obj) {
|
||||
const json = JSON.stringify(obj, null, 2) + '\n';
|
||||
// Validate the JSON we're about to write
|
||||
const reparsed = parseJson(json);
|
||||
if (reparsed === null) throw new Error('Generated invalid JSON');
|
||||
await writeFile(filePath, json, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to the expected type.
|
||||
*/
|
||||
function convertType(value, expectedType) {
|
||||
switch (expectedType) {
|
||||
case 'boolean':
|
||||
if (typeof value === 'string') {
|
||||
if (value.toLowerCase() === 'true' || value === '1') return true;
|
||||
if (value.toLowerCase() === 'false' || value === '0') return false;
|
||||
}
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
return Boolean(value);
|
||||
case 'number':
|
||||
if (typeof value === 'string') {
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? 10000 : num; // default 10000 for timeouts
|
||||
}
|
||||
return Number(value);
|
||||
case 'string':
|
||||
return String(value);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest valid effortLevel.
|
||||
*/
|
||||
function findNearestEffortLevel(value) {
|
||||
if (typeof value !== 'string') return 'medium';
|
||||
const lower = value.toLowerCase();
|
||||
// Simple distance-based matching
|
||||
let best = 'medium';
|
||||
let bestDist = Infinity;
|
||||
for (const level of VALID_EFFORT_LEVELS) {
|
||||
const dist = levenshtein(lower, level);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = level;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple Levenshtein distance.
|
||||
*/
|
||||
function levenshtein(a, b) {
|
||||
const m = a.length, n = b.length;
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
return dp[m][n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hooks array to object format.
|
||||
* Best-effort: wraps array items under "PreToolUse" if they have event fields,
|
||||
* otherwise groups by event property.
|
||||
*/
|
||||
function restructureHooksArrayToObject(parsed) {
|
||||
if (!Array.isArray(parsed.hooks)) return;
|
||||
|
||||
const hooksObj = {};
|
||||
for (const item of parsed.hooks) {
|
||||
const event = item.event || 'PreToolUse';
|
||||
if (!hooksObj[event]) hooksObj[event] = [];
|
||||
|
||||
// Build the handler group
|
||||
const group = {};
|
||||
if (item.matcher) group.matcher = typeof item.matcher === 'string' ? item.matcher : String(item.matcher);
|
||||
group.hooks = [];
|
||||
|
||||
if (item.command) {
|
||||
group.hooks.push({
|
||||
type: item.type || 'command',
|
||||
command: item.command,
|
||||
...(item.timeout !== undefined ? { timeout: typeof item.timeout === 'number' ? item.timeout : Number(item.timeout) || 10000 } : {}),
|
||||
});
|
||||
} else if (item.hooks && Array.isArray(item.hooks)) {
|
||||
group.hooks = item.hooks;
|
||||
}
|
||||
|
||||
hooksObj[event].push(group);
|
||||
}
|
||||
|
||||
parsed.hooks = hooksObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert matcher from object to string in hooks config.
|
||||
*/
|
||||
function restructureMatcherObjectToString(parsed, event) {
|
||||
const hooks = parsed.hooks || parsed;
|
||||
if (typeof hooks !== 'object' || Array.isArray(hooks)) return;
|
||||
|
||||
for (const [eventKey, handlers] of Object.entries(hooks)) {
|
||||
if (event && eventKey !== event) continue;
|
||||
if (!Array.isArray(handlers)) continue;
|
||||
|
||||
for (const group of handlers) {
|
||||
if (group.matcher && typeof group.matcher === 'object') {
|
||||
// Extract tool name from object — common patterns: { tool: "Bash" }, { name: "Bash" }
|
||||
const tool = group.matcher.tool || group.matcher.name || group.matcher.type || Object.values(group.matcher)[0];
|
||||
group.matcher = typeof tool === 'string' ? tool : 'Bash';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix timeout type in nested hooks config.
|
||||
*/
|
||||
function fixTimeoutInHooks(parsed, event) {
|
||||
const hooks = parsed.hooks || parsed;
|
||||
if (typeof hooks !== 'object' || Array.isArray(hooks)) return;
|
||||
|
||||
for (const [eventKey, handlers] of Object.entries(hooks)) {
|
||||
if (event && eventKey !== event) continue;
|
||||
if (!Array.isArray(handlers)) continue;
|
||||
|
||||
for (const group of handlers) {
|
||||
if (!group.hooks || !Array.isArray(group.hooks)) continue;
|
||||
for (const hook of group.hooks) {
|
||||
if (hook.timeout !== undefined && typeof hook.timeout !== 'number') {
|
||||
const num = Number(hook.timeout);
|
||||
hook.timeout = isNaN(num) ? 10000 : num;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key name from evidence string like: 'someKey: "value"'
|
||||
*/
|
||||
function extractKeyFromEvidence(evidence) {
|
||||
if (!evidence) return null;
|
||||
const match = evidence.match(/^(\w+)\s*:/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract expected type from description string like: 'should be boolean, got string'
|
||||
*/
|
||||
function extractExpectedType(description) {
|
||||
const match = description.match(/should be (\w+)/);
|
||||
return match ? match[1] : 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract event name from description like: '"PreToolUse" has a matcher...'
|
||||
*/
|
||||
function extractEventFromDescription(description) {
|
||||
const match = description.match(/"(\w+)"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify fixes by re-running affected scanners.
|
||||
* @param {object} originalEnvelope - Original scanner envelope
|
||||
* @param {object[]} appliedResults - Results from applyFixes()
|
||||
* @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
|
||||
*/
|
||||
export async function verifyFixes(originalEnvelope, appliedResults) {
|
||||
const targetPath = originalEnvelope.meta.target;
|
||||
const verified = [];
|
||||
const regressions = [];
|
||||
const newFindings = [];
|
||||
|
||||
// Re-scan the target
|
||||
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false });
|
||||
|
||||
// Build set of original finding IDs that were fixed
|
||||
const fixedIds = new Set(
|
||||
appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
|
||||
);
|
||||
|
||||
// Build set of new finding titles for comparison
|
||||
const newFindingMap = new Map();
|
||||
for (const scanner of newEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
newFindingMap.set(`${f.scanner}:${f.title}:${f.file}`, f);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that fixed findings are gone
|
||||
for (const scanner of originalEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
if (!fixedIds.has(f.id)) continue;
|
||||
|
||||
const key = `${f.scanner}:${f.title}:${f.file}`;
|
||||
// For file-rename fixes, the original file path won't exist anymore
|
||||
const fixResult = appliedResults.find(r => r.findingId === f.id);
|
||||
if (fixResult && fixResult.type === 'file-rename') {
|
||||
// Check that the finding doesn't reappear at the new path
|
||||
verified.push(f.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newFindingMap.has(key)) {
|
||||
regressions.push(f.id);
|
||||
} else {
|
||||
verified.push(f.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for any completely new findings not in original
|
||||
const originalKeys = new Set();
|
||||
for (const scanner of originalEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
originalKeys.add(`${f.scanner}:${f.title}:${f.file}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, f] of newFindingMap) {
|
||||
if (!originalKeys.has(key)) {
|
||||
newFindings.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
return { verified, regressions, newFindings };
|
||||
}
|
||||
|
||||
export { FIX_TYPES };
|
||||
270
plugins/config-audit/scanners/hook-validator.mjs
Normal file
270
plugins/config-audit/scanners/hook-validator.mjs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* HKV Scanner — Hook Validator
|
||||
* Validates hooks.json format, script existence, event validity, timeouts.
|
||||
* Finding IDs: CA-HKV-NNN
|
||||
*/
|
||||
|
||||
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
|
||||
const SCANNER = 'HKV';
|
||||
|
||||
/** All valid hook events (as of April 2026) */
|
||||
const VALID_EVENTS = new Set([
|
||||
'SessionStart', 'InstructionsLoaded', 'UserPromptSubmit',
|
||||
'PreToolUse', 'PermissionRequest', 'PermissionDenied',
|
||||
'PostToolUse', 'PostToolUseFailure',
|
||||
'SubagentStart', 'SubagentStop',
|
||||
'TaskCreated', 'TaskCompleted',
|
||||
'Stop', 'StopFailure',
|
||||
'TeammateIdle', 'Notification',
|
||||
'ConfigChange', 'CwdChanged', 'FileChanged',
|
||||
'WorktreeCreate', 'WorktreeRemove',
|
||||
'PreCompact', 'PostCompact',
|
||||
'Elicitation', 'ElicitationResult',
|
||||
'SessionEnd',
|
||||
]);
|
||||
|
||||
/** Valid hook handler types */
|
||||
const VALID_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
|
||||
|
||||
/** Reasonable timeout range */
|
||||
const MIN_TIMEOUT = 1000;
|
||||
const MAX_TIMEOUT = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Scan all hooks.json files and hook configs in settings.json.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const hooksFiles = discovery.files.filter(f => f.type === 'hooks-json');
|
||||
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
// Scan standalone hooks.json files
|
||||
for (const file of hooksFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
filesScanned++;
|
||||
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in hooks.json',
|
||||
description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Fix JSON syntax errors.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const hooksConfig = parsed.hooks || parsed;
|
||||
await validateHooksObject(hooksConfig, file, findings, dirname(file.absPath));
|
||||
}
|
||||
|
||||
// Scan hooks in settings.json files
|
||||
for (const file of settingsFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
|
||||
const parsed = parseJson(content);
|
||||
if (!parsed || !parsed.hooks) continue;
|
||||
filesScanned++;
|
||||
|
||||
if (Array.isArray(parsed.hooks)) {
|
||||
// Already reported by settings-validator, skip here
|
||||
continue;
|
||||
}
|
||||
|
||||
await validateHooksObject(parsed.hooks, file, findings, dirname(file.absPath));
|
||||
}
|
||||
|
||||
if (hooksFiles.length === 0 && !settingsFiles.some(async f => {
|
||||
const c = await readTextFile(f.absPath);
|
||||
const p = c ? parseJson(c) : null;
|
||||
return p && p.hooks;
|
||||
})) {
|
||||
// No hooks at all — this is noted but not an error
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a hooks object (event key → handler array).
|
||||
*/
|
||||
async function validateHooksObject(hooks, file, findings, baseDir) {
|
||||
if (typeof hooks !== 'object' || Array.isArray(hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Hooks must be an object with event keys',
|
||||
description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Use format: { "PreToolUse": [...], "Stop": [...] }',
|
||||
autoFixable: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [event, handlers] of Object.entries(hooks)) {
|
||||
// Validate event name
|
||||
if (!VALID_EVENTS.has(event)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Unknown hook event',
|
||||
description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
|
||||
file: file.absPath,
|
||||
evidence: event,
|
||||
recommendation: `Valid events: ${[...VALID_EVENTS].slice(0, 8).join(', ')}... (26 total)`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(handlers)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook handlers must be an array',
|
||||
description: `${file.relPath}: handlers for "${event}" is not an array.`,
|
||||
file: file.absPath,
|
||||
evidence: `"${event}": ${typeof handlers}`,
|
||||
recommendation: `Use format: "${event}": [{ "hooks": [...] }]`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const handlerGroup of handlers) {
|
||||
// Validate matcher format
|
||||
if (handlerGroup.matcher !== undefined) {
|
||||
if (typeof handlerGroup.matcher === 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Matcher must be a string, not an object',
|
||||
description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`,
|
||||
file: file.absPath,
|
||||
evidence: JSON.stringify(handlerGroup.matcher),
|
||||
recommendation: 'Change matcher to a string: "matcher": "Bash"',
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing hooks array in handler group',
|
||||
description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add "hooks": [{ "type": "command", "command": "..." }]',
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const hook of handlerGroup.hooks) {
|
||||
// Validate handler type
|
||||
if (!hook.type || !VALID_TYPES.has(hook.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hook handler type',
|
||||
description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`,
|
||||
file: file.absPath,
|
||||
evidence: `type: "${hook.type || ''}"`,
|
||||
recommendation: `Valid types: ${[...VALID_TYPES].join(', ')}`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// For command hooks, check script existence
|
||||
if (hook.type === 'command' && hook.command) {
|
||||
const scriptPath = extractScriptPath(hook.command, baseDir);
|
||||
if (scriptPath) {
|
||||
try {
|
||||
await stat(scriptPath);
|
||||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook script not found',
|
||||
description: `${file.relPath}: "${event}" references script that does not exist.`,
|
||||
file: file.absPath,
|
||||
evidence: hook.command,
|
||||
recommendation: `Create the script at: ${scriptPath}`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout validation
|
||||
if (hook.timeout !== undefined) {
|
||||
if (typeof hook.timeout !== 'number') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Hook timeout must be a number',
|
||||
description: `${file.relPath}: "${event}" has non-numeric timeout.`,
|
||||
file: file.absPath,
|
||||
evidence: `timeout: ${JSON.stringify(hook.timeout)}`,
|
||||
recommendation: 'Set timeout to a number (milliseconds).',
|
||||
autoFixable: true,
|
||||
}));
|
||||
} else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Hook timeout outside recommended range',
|
||||
description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`,
|
||||
file: file.absPath,
|
||||
evidence: `timeout: ${hook.timeout}`,
|
||||
recommendation: `Set timeout between ${MIN_TIMEOUT} and ${MAX_TIMEOUT}ms.`,
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a filesystem path from a hook command string.
|
||||
* Handles ${CLAUDE_PLUGIN_ROOT} variable substitution.
|
||||
*/
|
||||
function extractScriptPath(command, baseDir) {
|
||||
// Extract the script path from common patterns:
|
||||
// "bash /path/to/script.sh"
|
||||
// "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs"
|
||||
const match = command.match(/(?:bash|node|sh)\s+(.+?)(?:\s|$)/);
|
||||
if (!match) return null;
|
||||
|
||||
let scriptPath = match[1].trim();
|
||||
|
||||
// Replace ${CLAUDE_PLUGIN_ROOT} with baseDir (best guess)
|
||||
scriptPath = scriptPath.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, resolve(baseDir, '..'));
|
||||
scriptPath = scriptPath.replace(/\$CLAUDE_PLUGIN_ROOT/g, resolve(baseDir, '..'));
|
||||
|
||||
// Don't validate absolute paths that use env vars we can't resolve
|
||||
if (scriptPath.includes('$')) return null;
|
||||
|
||||
return resolve(baseDir, scriptPath);
|
||||
}
|
||||
185
plugins/config-audit/scanners/import-resolver.mjs
Normal file
185
plugins/config-audit/scanners/import-resolver.mjs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* IMP Scanner — Import Resolver
|
||||
* Resolves @import references in CLAUDE.md files: broken links, circular refs, deep chains.
|
||||
* Finding IDs: CA-IMP-NNN
|
||||
*/
|
||||
|
||||
import { resolve, dirname, basename } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { findImports } from './lib/yaml-parser.mjs';
|
||||
import { truncate } from './lib/string-utils.mjs';
|
||||
|
||||
const SCANNER = 'IMP';
|
||||
const MAX_CHAIN_DEPTH = 5;
|
||||
const HARD_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Check if a file exists.
|
||||
* @param {string} absPath
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fileExists(absPath) {
|
||||
try {
|
||||
await stat(absPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an import path relative to the containing file.
|
||||
* @param {string} importPath
|
||||
* @param {string} containingFile
|
||||
* @returns {{ resolved: string, hasTilde: boolean }}
|
||||
*/
|
||||
function resolveImportPath(importPath, containingFile) {
|
||||
const hasTilde = importPath.startsWith('~');
|
||||
let resolved;
|
||||
|
||||
if (hasTilde) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || tmpdir();
|
||||
resolved = resolve(importPath.replace(/^~/, home));
|
||||
} else if (importPath.startsWith('/')) {
|
||||
resolved = importPath;
|
||||
} else {
|
||||
resolved = resolve(dirname(containingFile), importPath);
|
||||
}
|
||||
|
||||
return { resolved, hasTilde };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk imports recursively from a starting file via DFS.
|
||||
* @param {string} file - Absolute path to current file
|
||||
* @param {string[]} chain - Current chain of files (for cycle detection)
|
||||
* @param {Set<string>} reported - Set of "from::to" pairs already reported
|
||||
* @param {object[]} findings - Accumulator for findings
|
||||
*/
|
||||
async function walkImports(file, chain, reported, findings) {
|
||||
const content = await readTextFile(file);
|
||||
if (!content) return;
|
||||
|
||||
const imports = findImports(content);
|
||||
for (const imp of imports) {
|
||||
const { resolved, hasTilde } = resolveImportPath(imp.path, file);
|
||||
const reportKey = `${file}::${resolved}`;
|
||||
|
||||
// Tilde path warning
|
||||
if (hasTilde && !reported.has(`tilde::${resolved}`)) {
|
||||
reported.add(`tilde::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Tilde path in @import',
|
||||
description: `@${imp.path} uses ~ which may not expand correctly in all contexts.`,
|
||||
file,
|
||||
line: imp.line,
|
||||
evidence: `@${imp.path}`,
|
||||
recommendation: 'Use a relative path or absolute path without tilde expansion.',
|
||||
}));
|
||||
}
|
||||
|
||||
// Check file existence
|
||||
const exists = await fileExists(resolved);
|
||||
if (!exists) {
|
||||
if (!reported.has(reportKey)) {
|
||||
reported.add(reportKey);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Broken @import link',
|
||||
description: `@${imp.path} references a file that does not exist.`,
|
||||
file,
|
||||
line: imp.line,
|
||||
evidence: `@${imp.path} → ${truncate(resolved, 80)}`,
|
||||
recommendation: 'Fix the path or create the missing file.',
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Circular reference detection
|
||||
if (chain.includes(resolved)) {
|
||||
if (!reported.has(reportKey)) {
|
||||
reported.add(reportKey);
|
||||
const cycleStart = chain.indexOf(resolved);
|
||||
const cycle = chain.slice(cycleStart).map(f => basename(f)).join(' → ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Circular @import reference',
|
||||
description: `@${imp.path} creates a circular import chain.`,
|
||||
file,
|
||||
line: imp.line,
|
||||
evidence: `${cycle} → ${basename(resolved)}`,
|
||||
recommendation: 'Break the circular dependency by removing one of the @imports.',
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deep chain warning
|
||||
if (chain.length >= MAX_CHAIN_DEPTH) {
|
||||
if (!reported.has(`deep::${resolved}`)) {
|
||||
reported.add(`deep::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Deep @import chain',
|
||||
description: `@${imp.path} is at depth ${chain.length} (>${MAX_CHAIN_DEPTH} hops).`,
|
||||
file,
|
||||
line: imp.line,
|
||||
evidence: `Chain depth: ${chain.length}`,
|
||||
recommendation: 'Flatten the import hierarchy to reduce nesting.',
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hard limit safety bail
|
||||
if (chain.length >= HARD_LIMIT) continue;
|
||||
|
||||
// Recurse
|
||||
await walkImports(resolved, [...chain, resolved], reported, findings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all CLAUDE.md files for @import issues.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const claudeMdFiles = discovery.files.filter(f => f.type === 'claude-md');
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
if (claudeMdFiles.length === 0) {
|
||||
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
const reported = new Set();
|
||||
|
||||
for (const file of claudeMdFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
|
||||
const imports = findImports(content);
|
||||
if (imports.length === 0) {
|
||||
filesScanned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
filesScanned++;
|
||||
await walkImports(file.absPath, [file.absPath], reported, findings);
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
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;
|
||||
}
|
||||
153
plugins/config-audit/scanners/mcp-config-validator.mjs
Normal file
153
plugins/config-audit/scanners/mcp-config-validator.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* MCP Scanner — MCP Configuration Validator
|
||||
* Validates .mcp.json files: server types, trust levels, env vars, unknown fields.
|
||||
* Finding IDs: CA-MCP-NNN
|
||||
*/
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { truncate } from './lib/string-utils.mjs';
|
||||
|
||||
const SCANNER = 'MCP';
|
||||
|
||||
const VALID_SERVER_TYPES = new Set(['stdio', 'http', 'sse']);
|
||||
const VALID_TRUST_LEVELS = new Set(['workspace', 'trusted', 'untrusted']);
|
||||
const VALID_SERVER_FIELDS = new Set([
|
||||
'type', 'command', 'args', 'env', 'url', 'headers', 'timeout', 'trust',
|
||||
]);
|
||||
|
||||
const ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
|
||||
|
||||
/**
|
||||
* Scan all .mcp.json files discovered.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const mcpFiles = discovery.files.filter(f => f.type === 'mcp-json');
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
if (mcpFiles.length === 0) {
|
||||
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
for (const file of mcpFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
filesScanned++;
|
||||
|
||||
const parsed = parseJson(content);
|
||||
if (!parsed) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in MCP config',
|
||||
description: `${file.relPath}: Failed to parse as JSON.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Fix JSON syntax errors. Use a JSON validator to check the file.',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const servers = parsed.mcpServers || parsed;
|
||||
if (typeof servers !== 'object' || Array.isArray(servers)) continue;
|
||||
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) continue;
|
||||
|
||||
// Check server type
|
||||
if (config.type && !VALID_SERVER_TYPES.has(config.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Unknown MCP server type',
|
||||
description: `${file.relPath}: Server "${name}" has unknown type "${config.type}".`,
|
||||
file: file.absPath,
|
||||
evidence: `type: "${config.type}"`,
|
||||
recommendation: `Use one of: stdio, http, sse. Got "${config.type}".`,
|
||||
}));
|
||||
}
|
||||
|
||||
// SSE → HTTP recommendation
|
||||
if (config.type === 'sse') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'SSE server type — consider HTTP',
|
||||
description: `${file.relPath}: Server "${name}" uses "sse" type. The "http" type is the current standard.`,
|
||||
file: file.absPath,
|
||||
evidence: `type: "sse"`,
|
||||
recommendation: 'Migrate from "sse" to "http" type for better compatibility.',
|
||||
}));
|
||||
}
|
||||
|
||||
// Check trust level
|
||||
if (!config.trust) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Missing trust level',
|
||||
description: `${file.relPath}: Server "${name}" has no trust level configured.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add "trust": "workspace"|"trusted"|"untrusted" to explicitly set the trust level.',
|
||||
}));
|
||||
} else if (!VALID_TRUST_LEVELS.has(config.trust)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid trust level',
|
||||
description: `${file.relPath}: Server "${name}" has invalid trust level "${config.trust}".`,
|
||||
file: file.absPath,
|
||||
evidence: `trust: "${config.trust}"`,
|
||||
recommendation: 'Use one of: workspace, trusted, untrusted.',
|
||||
}));
|
||||
}
|
||||
|
||||
// Check for env var references in args without env block
|
||||
if (Array.isArray(config.args)) {
|
||||
for (const arg of config.args) {
|
||||
if (typeof arg !== 'string') continue;
|
||||
let match;
|
||||
ENV_VAR_PATTERN.lastIndex = 0;
|
||||
while ((match = ENV_VAR_PATTERN.exec(arg)) !== null) {
|
||||
const varName = match[1];
|
||||
const hasEnvBlock = config.env && typeof config.env === 'object' && varName in config.env;
|
||||
if (!hasEnvBlock) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unreferenced env var in args',
|
||||
description: `${file.relPath}: Server "${name}" references \${${varName}} in args but has no env block defining it.`,
|
||||
file: file.absPath,
|
||||
evidence: truncate(arg, 80),
|
||||
recommendation: `Add an "env" block with "${varName}" or remove the variable reference.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unknown fields
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!VALID_SERVER_FIELDS.has(key)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unknown MCP server field',
|
||||
description: `${file.relPath}: Server "${name}" has unknown field "${key}".`,
|
||||
file: file.absPath,
|
||||
evidence: `${key}: ${truncate(JSON.stringify(config[key]), 60)}`,
|
||||
recommendation: `Remove or correct "${key}". Valid fields: ${[...VALID_SERVER_FIELDS].join(', ')}.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
455
plugins/config-audit/scanners/plugin-health-scanner.mjs
Normal file
455
plugins/config-audit/scanners/plugin-health-scanner.mjs
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* PLH Scanner — Plugin Health
|
||||
* Validates Claude Code plugin structure, frontmatter, and cross-plugin coherence.
|
||||
* Finding IDs: CA-PLH-NNN
|
||||
* NOT included in scan-orchestrator — runs independently on plugin directories.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { join, basename, resolve } from 'node:path';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
|
||||
const SCANNER = 'PLH';
|
||||
|
||||
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
|
||||
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
|
||||
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores)
|
||||
const REQUIRED_COMMAND_FRONTMATTER = [
|
||||
{ key: 'name', display: 'name' },
|
||||
{ key: 'description', display: 'description' },
|
||||
{ key: 'model', display: 'model' },
|
||||
{ key: 'allowed_tools', display: 'allowed-tools' },
|
||||
];
|
||||
const REQUIRED_AGENT_FRONTMATTER = [
|
||||
{ key: 'name', display: 'name' },
|
||||
{ key: 'description', display: 'description' },
|
||||
{ key: 'model', display: 'model' },
|
||||
{ key: 'tools', display: 'tools' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Discover plugins under a path.
|
||||
* Looks for .claude-plugin/plugin.json pattern.
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<string[]>} Array of plugin root directories
|
||||
*/
|
||||
export async function discoverPlugins(targetPath) {
|
||||
const plugins = [];
|
||||
|
||||
// Check if targetPath itself is a plugin
|
||||
if (await isPlugin(targetPath)) {
|
||||
plugins.push(targetPath);
|
||||
return plugins;
|
||||
}
|
||||
|
||||
// Look for plugins in subdirectories (marketplace layout: plugins/<name>/)
|
||||
try {
|
||||
const entries = await readdir(targetPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const subDir = join(targetPath, entry.name);
|
||||
if (await isPlugin(subDir)) {
|
||||
plugins.push(subDir);
|
||||
continue;
|
||||
}
|
||||
// Also check one level deeper (plugins/<name>/ layout)
|
||||
try {
|
||||
const subEntries = await readdir(subDir, { withFileTypes: true });
|
||||
for (const subEntry of subEntries) {
|
||||
if (!subEntry.isDirectory()) continue;
|
||||
const deepDir = join(subDir, subEntry.name);
|
||||
if (await isPlugin(deepDir)) {
|
||||
plugins.push(deepDir);
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory is a Claude Code plugin.
|
||||
* @param {string} dir
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function isPlugin(dir) {
|
||||
try {
|
||||
await stat(join(dir, '.claude-plugin', 'plugin.json'));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single plugin for health issues.
|
||||
* @param {string} pluginDir - Plugin root directory
|
||||
* @returns {Promise<{ name: string, findings: object[], commandCount: number, agentCount: number }>}
|
||||
*/
|
||||
async function scanSinglePlugin(pluginDir) {
|
||||
const findings = [];
|
||||
const pluginName = basename(pluginDir);
|
||||
let commandCount = 0;
|
||||
let agentCount = 0;
|
||||
|
||||
// 1. Validate plugin.json
|
||||
const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
|
||||
try {
|
||||
const content = await readFile(pluginJsonPath, 'utf-8');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid plugin.json',
|
||||
description: `plugin.json is not valid JSON in ${pluginName}`,
|
||||
file: pluginJsonPath,
|
||||
}));
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (parsed) {
|
||||
for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
|
||||
if (!parsed[field]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: `Missing required field in plugin.json: ${field}`,
|
||||
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
|
||||
file: pluginJsonPath,
|
||||
recommendation: `Add "${field}" to plugin.json`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Missing plugin.json',
|
||||
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
|
||||
file: pluginDir,
|
||||
recommendation: 'Create .claude-plugin/plugin.json with name, description, version',
|
||||
}));
|
||||
}
|
||||
|
||||
// 2. Validate CLAUDE.md
|
||||
const claudeMdPath = join(pluginDir, 'CLAUDE.md');
|
||||
try {
|
||||
const content = await readFile(claudeMdPath, 'utf-8');
|
||||
const lower = content.toLowerCase();
|
||||
|
||||
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
|
||||
// Look for markdown table header or section header
|
||||
const hasSection = lower.includes(`## ${section}`) ||
|
||||
lower.includes(`| ${section}`) ||
|
||||
lower.includes(`|${section}`);
|
||||
if (!hasSection) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: `CLAUDE.md missing ${section} section`,
|
||||
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
|
||||
file: claudeMdPath,
|
||||
recommendation: `Add a "## ${section.charAt(0).toUpperCase() + section.slice(1)}" section with a table`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing CLAUDE.md',
|
||||
description: `Plugin "${pluginName}" has no CLAUDE.md`,
|
||||
file: pluginDir,
|
||||
recommendation: 'Create CLAUDE.md with Commands, Agents, and Hooks tables',
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. Validate commands frontmatter
|
||||
const commandsDir = join(pluginDir, 'commands');
|
||||
try {
|
||||
const entries = await readdir(commandsDir);
|
||||
const mdFiles = entries.filter(f => f.endsWith('.md'));
|
||||
commandCount = mdFiles.length;
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const filePath = join(commandsDir, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatter) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Command missing frontmatter',
|
||||
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
|
||||
file: filePath,
|
||||
recommendation: 'Add YAML frontmatter with name, description, model',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { key, display } of REQUIRED_COMMAND_FRONTMATTER) {
|
||||
if (!frontmatter[key]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: `Command missing frontmatter field: ${display}`,
|
||||
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
||||
file: filePath,
|
||||
recommendation: `Add "${display}" to frontmatter`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no commands dir */ }
|
||||
|
||||
// 4. Validate agents frontmatter
|
||||
const agentsDir = join(pluginDir, 'agents');
|
||||
try {
|
||||
const entries = await readdir(agentsDir);
|
||||
const mdFiles = entries.filter(f => f.endsWith('.md'));
|
||||
agentCount = mdFiles.length;
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const filePath = join(agentsDir, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatter) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Agent missing frontmatter',
|
||||
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
|
||||
file: filePath,
|
||||
recommendation: 'Add YAML frontmatter with name, description, model, tools',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { key, display } of REQUIRED_AGENT_FRONTMATTER) {
|
||||
if (!frontmatter[key]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: `Agent missing frontmatter field: ${display}`,
|
||||
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
||||
file: filePath,
|
||||
recommendation: `Add "${display}" to frontmatter`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no agents dir */ }
|
||||
|
||||
// 5. Validate hooks.json (if exists)
|
||||
const hooksJsonPath = join(pluginDir, 'hooks', 'hooks.json');
|
||||
try {
|
||||
const content = await readFile(hooksJsonPath, 'utf-8');
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hooks.json structure',
|
||||
description: `hooks.json in "${pluginName}" missing "hooks" object`,
|
||||
file: hooksJsonPath,
|
||||
recommendation: 'hooks.json must have a "hooks" key with event-keyed object',
|
||||
}));
|
||||
} else if (Array.isArray(parsed.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'hooks.json uses array instead of object',
|
||||
description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
|
||||
file: hooksJsonPath,
|
||||
recommendation: 'Change hooks from array to object: { "PreToolUse": [...], ... }',
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hooks.json',
|
||||
description: `hooks.json is not valid JSON in "${pluginName}"`,
|
||||
file: hooksJsonPath,
|
||||
}));
|
||||
}
|
||||
} catch { /* no hooks.json — fine */ }
|
||||
|
||||
// 6. Check for unknown files in .claude-plugin/
|
||||
const pluginMetaDir = join(pluginDir, '.claude-plugin');
|
||||
try {
|
||||
const entries = await readdir(pluginMetaDir);
|
||||
const known = new Set(['plugin.json']);
|
||||
for (const entry of entries) {
|
||||
if (!known.has(entry)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Unknown file in .claude-plugin/',
|
||||
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
|
||||
file: join(pluginMetaDir, entry),
|
||||
recommendation: 'Only plugin.json should be in .claude-plugin/',
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
return { name: pluginName, findings, commandCount, agentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan one or more plugins and return aggregated results.
|
||||
* @param {string} targetPath - Plugin dir or marketplace root
|
||||
* @returns {Promise<object>} Scanner result
|
||||
*/
|
||||
export async function scan(targetPath) {
|
||||
const start = Date.now();
|
||||
resetCounter();
|
||||
|
||||
const pluginDirs = await discoverPlugins(resolve(targetPath));
|
||||
|
||||
if (pluginDirs.length === 0) {
|
||||
return scannerResult(SCANNER, 'ok', [
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'No plugins found',
|
||||
description: `No Claude Code plugins found under ${targetPath}`,
|
||||
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
|
||||
}),
|
||||
], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
const allFindings = [];
|
||||
const pluginResults = [];
|
||||
|
||||
for (const dir of pluginDirs) {
|
||||
const result = await scanSinglePlugin(dir);
|
||||
pluginResults.push(result);
|
||||
allFindings.push(...result.findings);
|
||||
}
|
||||
|
||||
// Cross-plugin checks: command name conflicts
|
||||
const commandNames = new Map(); // name → plugin
|
||||
for (let idx = 0; idx < pluginResults.length; idx++) {
|
||||
const pr = pluginResults[idx];
|
||||
const commandsDir = join(pluginDirs[idx], 'commands');
|
||||
try {
|
||||
const entries = await readdir(commandsDir);
|
||||
for (const file of entries.filter(f => f.endsWith('.md'))) {
|
||||
const filePath = join(commandsDir, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const { frontmatter } = parseFrontmatter(content);
|
||||
if (frontmatter && frontmatter.name) {
|
||||
const cmdName = frontmatter.name;
|
||||
if (commandNames.has(cmdName)) {
|
||||
allFindings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Cross-plugin command name conflict',
|
||||
description: `Command "${cmdName}" exists in both "${commandNames.get(cmdName)}" and "${pr.name}"`,
|
||||
file: filePath,
|
||||
recommendation: `Rename one of the conflicting commands to avoid ambiguity`,
|
||||
}));
|
||||
} else {
|
||||
commandNames.set(cmdName, pr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no commands dir */ }
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a plugin health report for terminal output.
|
||||
* @param {object} scanResult - Scanner result from scan()
|
||||
* @param {Array<{ name: string, findings: object[], commandCount: number, agentCount: number }>} pluginResults
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
|
||||
const lines = [];
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
lines.push(' Plugin Health Report');
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
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';
|
||||
const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
|
||||
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
if (crossPluginFindings.length > 0) {
|
||||
lines.push(` Cross-plugin issues (${crossPluginFindings.length}):`);
|
||||
for (const f of crossPluginFindings) {
|
||||
lines.push(` - [${f.severity}] ${f.title}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(' Cross-plugin issues (0):');
|
||||
lines.push(' (none)');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// --- CLI entry point ---
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
let jsonMode = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write(`Plugin Health Scanner v2.1.0\n`);
|
||||
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
|
||||
|
||||
const result = await scan(targetPath);
|
||||
|
||||
if (jsonMode) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
} else {
|
||||
// Brief summary
|
||||
const count = result.findings.length;
|
||||
process.stderr.write(`Findings: ${count}\n`);
|
||||
for (const f of result.findings) {
|
||||
process.stderr.write(` [${f.severity}] ${f.title}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
111
plugins/config-audit/scanners/posture.mjs
Normal file
111
plugins/config-audit/scanners/posture.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Config-Audit Posture Assessment CLI
|
||||
* Runs all scanners + scoring in a single Node.js process.
|
||||
* Usage: node posture.mjs <target-path> [--json] [--global] [--output-file path]
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import {
|
||||
calculateUtilization,
|
||||
determineMaturityLevel,
|
||||
determineSegment,
|
||||
scoreByArea,
|
||||
topActions,
|
||||
generateScorecard,
|
||||
generateHealthScorecard,
|
||||
} from './lib/scoring.mjs';
|
||||
|
||||
/**
|
||||
* Run posture assessment and return structured result.
|
||||
* @param {string} targetPath
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.includeGlobal=false]
|
||||
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function runPosture(targetPath, opts = {}) {
|
||||
const envelope = await runAllScanners(targetPath, opts);
|
||||
|
||||
// Extract GAP scanner results
|
||||
const gapScanner = envelope.scanners.find(s => s.scanner === 'GAP');
|
||||
const gapFindings = gapScanner ? gapScanner.findings : [];
|
||||
|
||||
// Calculate scores
|
||||
const utilization = calculateUtilization(gapFindings);
|
||||
const maturity = determineMaturityLevel(gapFindings, { files: [] });
|
||||
const segment = determineSegment(utilization.score);
|
||||
const areaScores = scoreByArea(envelope.scanners);
|
||||
const actions = topActions(gapFindings);
|
||||
|
||||
return {
|
||||
utilization,
|
||||
maturity,
|
||||
segment,
|
||||
areas: areaScores.areas,
|
||||
overallGrade: areaScores.overallGrade,
|
||||
topActions: actions,
|
||||
opportunityCount: gapFindings.length,
|
||||
scannerEnvelope: envelope,
|
||||
};
|
||||
}
|
||||
|
||||
// --- CLI entry point ---
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let jsonMode = false;
|
||||
let includeGlobal = false;
|
||||
let fullMachine = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--output-file' && args[i + 1]) {
|
||||
outputFile = args[++i];
|
||||
} else if (args[i] === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (args[i] === '--global') {
|
||||
includeGlobal = true;
|
||||
} else if (args[i] === '--full-machine') {
|
||||
fullMachine = true;
|
||||
} else if (args[i] === '--include-fixtures') {
|
||||
// handled below
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
const filterFixtures = !args.includes('--include-fixtures');
|
||||
const result = await runPosture(targetPath, { includeGlobal, fullMachine, filterFixtures });
|
||||
|
||||
if (jsonMode) {
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
process.stdout.write(json + '\n');
|
||||
} else {
|
||||
// Terminal scorecard (v3 health format)
|
||||
const scorecard = generateHealthScorecard(
|
||||
{ areas: result.areas, overallGrade: result.overallGrade },
|
||||
result.opportunityCount,
|
||||
);
|
||||
process.stderr.write('\n' + scorecard + '\n');
|
||||
}
|
||||
|
||||
if (outputFile) {
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Only run CLI if invoked directly
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
166
plugins/config-audit/scanners/rollback-engine.mjs
Normal file
166
plugins/config-audit/scanners/rollback-engine.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Config-Audit Rollback Engine
|
||||
* Restores configuration from backup with checksum verification.
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getBackupDir, parseManifest, checksum } from './lib/backup.mjs';
|
||||
|
||||
/**
|
||||
* List all available backups.
|
||||
* @returns {Promise<{ backups: object[] }>}
|
||||
*/
|
||||
export async function listBackups() {
|
||||
const backupRoot = getBackupDir();
|
||||
const backups = [];
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(backupRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return { backups: [] };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const backupPath = join(backupRoot, entry.name);
|
||||
const manifestPath = join(backupPath, 'manifest.yaml');
|
||||
|
||||
try {
|
||||
const manifestContent = await readFile(manifestPath, 'utf-8');
|
||||
const manifest = parseManifest(manifestContent);
|
||||
|
||||
backups.push({
|
||||
id: entry.name,
|
||||
createdAt: manifest.created_at,
|
||||
files: manifest.files.map(f => ({
|
||||
originalPath: f.originalPath,
|
||||
backupPath: f.backupPath,
|
||||
checksum: f.checksum,
|
||||
sizeBytes: f.sizeBytes,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
// Skip backups without valid manifest
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
backups.sort((a, b) => b.id.localeCompare(a.id));
|
||||
|
||||
return { backups };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore files from a backup.
|
||||
* @param {string} backupId
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.dryRun=false]
|
||||
* @param {boolean} [opts.verify=true]
|
||||
* @returns {Promise<{ restored: object[], failed: object[] }>}
|
||||
*/
|
||||
export async function restoreBackup(backupId, opts = {}) {
|
||||
const verify = opts.verify !== false;
|
||||
const backupRoot = getBackupDir();
|
||||
const backupPath = join(backupRoot, backupId);
|
||||
const manifestPath = join(backupPath, 'manifest.yaml');
|
||||
|
||||
// Read manifest
|
||||
let manifestContent;
|
||||
try {
|
||||
manifestContent = await readFile(manifestPath, 'utf-8');
|
||||
} catch {
|
||||
throw new Error(`Backup not found: ${backupId}`);
|
||||
}
|
||||
|
||||
const manifest = parseManifest(manifestContent);
|
||||
const restored = [];
|
||||
const failed = [];
|
||||
|
||||
for (const fileEntry of manifest.files) {
|
||||
const backupFilePath = join(backupPath, fileEntry.backupPath);
|
||||
|
||||
if (opts.dryRun) {
|
||||
restored.push({
|
||||
originalPath: fileEntry.originalPath,
|
||||
status: 'dry-run',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read backup file
|
||||
const content = await readFile(backupFilePath);
|
||||
|
||||
// Verify checksum before restoring
|
||||
if (verify) {
|
||||
const hash = checksum(content);
|
||||
if (hash !== fileEntry.checksum) {
|
||||
failed.push({
|
||||
originalPath: fileEntry.originalPath,
|
||||
status: 'checksum-mismatch',
|
||||
error: `Expected ${fileEntry.checksum}, got ${hash}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Write to original path
|
||||
await writeFile(fileEntry.originalPath, content);
|
||||
|
||||
// Verify after write
|
||||
if (verify) {
|
||||
const written = await readFile(fileEntry.originalPath);
|
||||
const writtenHash = checksum(written);
|
||||
if (writtenHash !== fileEntry.checksum) {
|
||||
failed.push({
|
||||
originalPath: fileEntry.originalPath,
|
||||
status: 'checksum-mismatch',
|
||||
error: 'Checksum mismatch after write',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
restored.push({
|
||||
originalPath: fileEntry.originalPath,
|
||||
status: 'restored',
|
||||
});
|
||||
} catch (err) {
|
||||
failed.push({
|
||||
originalPath: fileEntry.originalPath,
|
||||
status: 'failed',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { restored, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup directory.
|
||||
* @param {string} backupId
|
||||
* @returns {Promise<{ deleted: boolean, error?: string }>}
|
||||
*/
|
||||
export async function deleteBackup(backupId) {
|
||||
const backupRoot = getBackupDir();
|
||||
const backupPath = join(backupRoot, backupId);
|
||||
|
||||
try {
|
||||
await stat(backupPath);
|
||||
} catch {
|
||||
return { deleted: false, error: `Backup not found: ${backupId}` };
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(backupPath, { recursive: true, force: true });
|
||||
return { deleted: true };
|
||||
} catch (err) {
|
||||
return { deleted: false, error: err.message };
|
||||
}
|
||||
}
|
||||
217
plugins/config-audit/scanners/rules-validator.mjs
Normal file
217
plugins/config-audit/scanners/rules-validator.mjs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* RUL Scanner — Rules Validator
|
||||
* Validates .claude/rules/ files: glob matching against real files, orphan detection, frontmatter.
|
||||
* Finding IDs: CA-RUL-NNN
|
||||
*/
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { lineCount, truncate } from './lib/string-utils.mjs';
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { join, resolve, relative } from 'node:path';
|
||||
|
||||
const SCANNER = 'RUL';
|
||||
|
||||
/**
|
||||
* Scan .claude/rules/ directories for issues.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const ruleFiles = discovery.files.filter(f => f.type === 'rule');
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
if (ruleFiles.length === 0) {
|
||||
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
// Collect all real files in the project for glob matching
|
||||
const projectFiles = await collectProjectFiles(targetPath);
|
||||
|
||||
for (const file of ruleFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
filesScanned++;
|
||||
|
||||
const { frontmatter, body, bodyStartLine } = parseFrontmatter(content);
|
||||
const lines = lineCount(content);
|
||||
|
||||
// --- Frontmatter checks ---
|
||||
if (!frontmatter) {
|
||||
// Rules without frontmatter are "always on" — not necessarily wrong, just note it
|
||||
if (lines > 5) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'Rule has no frontmatter (always active)',
|
||||
description: `${file.relPath} has no YAML frontmatter. It will be loaded for ALL files. Add paths: frontmatter to scope it.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add frontmatter with paths: to limit when this rule applies.',
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Check for paths/globs frontmatter
|
||||
const paths = frontmatter.paths || frontmatter.globs;
|
||||
|
||||
if (frontmatter.globs && !frontmatter.paths) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Rule uses deprecated "globs" field',
|
||||
description: `${file.relPath} uses "globs:" which is legacy. Use "paths:" instead.`,
|
||||
file: file.absPath,
|
||||
evidence: `globs: ${JSON.stringify(frontmatter.globs)}`,
|
||||
recommendation: 'Rename "globs:" to "paths:" in frontmatter.',
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
if (paths) {
|
||||
const patterns = Array.isArray(paths) ? paths : [paths];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (typeof pattern !== 'string') continue;
|
||||
|
||||
// Check if pattern matches any real files
|
||||
const matchCount = countGlobMatches(pattern, projectFiles, targetPath);
|
||||
if (matchCount === 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Rule path pattern matches no files',
|
||||
description: `${file.relPath}: pattern "${pattern}" matches 0 files. This rule will never activate.`,
|
||||
file: file.absPath,
|
||||
evidence: `paths: "${pattern}"`,
|
||||
recommendation: 'Check the glob pattern. Common issues: wrong directory name, missing **, incorrect extension.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content quality checks ---
|
||||
if (lines < 2) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'Rule file is nearly empty',
|
||||
description: `${file.relPath} has only ${lines} line(s).`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add meaningful content or remove the file.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// Check for overly broad rules (huge files without path scoping)
|
||||
if (!frontmatter?.paths && !frontmatter?.globs && lines > 50) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Large unscoped rule file',
|
||||
description: `${file.relPath} has ${lines} lines and no path scoping. It loads into context for every file interaction.`,
|
||||
file: file.absPath,
|
||||
evidence: `${lines} lines, no paths: frontmatter`,
|
||||
recommendation: 'Add paths: frontmatter to scope this rule, or split into smaller path-specific rules.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (!file.absPath.endsWith('.md')) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Rule file is not .md',
|
||||
description: `${file.relPath} is not a .md file. Only .md files are loaded from rules/.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Rename to .md extension.',
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect project file paths for glob matching (limited depth).
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async function collectProjectFiles(targetPath, depth = 0) {
|
||||
if (depth > 4) return [];
|
||||
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', 'vendor']);
|
||||
const files = [];
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(targetPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return files;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(targetPath, entry.name);
|
||||
if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
} else if (entry.isDirectory() && !SKIP.has(entry.name) && !entry.name.startsWith('.')) {
|
||||
const subFiles = await collectProjectFiles(fullPath, depth + 1);
|
||||
files.push(...subFiles);
|
||||
if (files.length > 5000) break; // Safety limit
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many files match a simplified glob pattern.
|
||||
* Supports: *, **, specific extensions.
|
||||
* @param {string} pattern
|
||||
* @param {string[]} files
|
||||
* @param {string} basePath
|
||||
* @returns {number}
|
||||
*/
|
||||
function countGlobMatches(pattern, files, basePath) {
|
||||
try {
|
||||
const regex = globToRegex(pattern);
|
||||
let count = 0;
|
||||
for (const file of files) {
|
||||
const rel = relative(basePath, file);
|
||||
if (regex.test(rel)) count++;
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return -1; // Pattern parsing error — don't report as orphan
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a simple glob pattern to a regex.
|
||||
* Handles ** matching zero or more path segments.
|
||||
* @param {string} pattern
|
||||
* @returns {RegExp}
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let regex = pattern
|
||||
.replace(/\./g, '\\.')
|
||||
.replace(/\/\*\*\//g, '{{GLOBSTAR_SLASH}}')
|
||||
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:/.+/|/)') // **/ matches 0+ intermediate dirs
|
||||
.replace(/\{\{GLOBSTAR\}\}/g, '.*')
|
||||
.replace(/\?/g, '[^/]');
|
||||
|
||||
// Handle leading patterns
|
||||
if (!regex.startsWith('.*') && !regex.startsWith('/')) {
|
||||
regex = '(?:^|/)' + regex;
|
||||
}
|
||||
|
||||
return new RegExp(regex);
|
||||
}
|
||||
248
plugins/config-audit/scanners/scan-orchestrator.mjs
Normal file
248
plugins/config-audit/scanners/scan-orchestrator.mjs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Config-Audit Scan Orchestrator
|
||||
* Runs all registered scanners sequentially, collects findings, outputs JSON envelope.
|
||||
* Usage: node scan-orchestrator.mjs <target-path> [--output-file path] [--save-baseline] [--baseline path]
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { envelope } from './lib/output.mjs';
|
||||
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
|
||||
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
|
||||
|
||||
// Scanner registry — import order determines execution order
|
||||
import { scan as scanClaudeMd } from './claude-md-linter.mjs';
|
||||
import { scan as scanSettings } from './settings-validator.mjs';
|
||||
import { scan as scanHooks } from './hook-validator.mjs';
|
||||
import { scan as scanRules } from './rules-validator.mjs';
|
||||
import { scan as scanMcp } from './mcp-config-validator.mjs';
|
||||
import { scan as scanImports } from './import-resolver.mjs';
|
||||
import { scan as scanConflicts } from './conflict-detector.mjs';
|
||||
import { scan as scanGap } from './feature-gap-scanner.mjs';
|
||||
|
||||
// Directory names that identify test fixture / example directories
|
||||
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
|
||||
|
||||
/**
|
||||
* Check if a finding originates from a test fixture or example directory
|
||||
* relative to the scan target. Only filters when the finding's path extends
|
||||
* beyond the target into a fixture subdirectory — if the target itself is
|
||||
* a fixture directory, findings are NOT filtered.
|
||||
* @param {object} f - Finding object
|
||||
* @param {string} targetPath - Resolved scan target path
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isFixturePath(f, targetPath) {
|
||||
const p = f.file || f.path || f.location || '';
|
||||
if (!p || !p.startsWith(targetPath)) return false;
|
||||
// Get the path relative to target, then check if it passes through a fixture dir
|
||||
const rel = p.slice(targetPath.length);
|
||||
return FIXTURE_DIR_NAMES.some(dir => rel.includes(sep + dir + sep));
|
||||
}
|
||||
|
||||
const SCANNERS = [
|
||||
{ name: 'CML', fn: scanClaudeMd, label: 'CLAUDE.md Linter' },
|
||||
{ name: 'SET', fn: scanSettings, label: 'Settings Validator' },
|
||||
{ name: 'HKV', fn: scanHooks, label: 'Hook Validator' },
|
||||
{ name: 'RUL', fn: scanRules, label: 'Rules Validator' },
|
||||
{ name: 'MCP', fn: scanMcp, label: 'MCP Config Validator' },
|
||||
{ name: 'IMP', fn: scanImports, label: 'Import Resolver' },
|
||||
{ name: 'CNF', fn: scanConflicts, label: 'Conflict Detector' },
|
||||
{ name: 'GAP', fn: scanGap, label: 'Feature Gap Scanner' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Run all scanners against target path.
|
||||
* @param {string} targetPath
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.includeGlobal=false]
|
||||
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
|
||||
* @param {boolean} [opts.suppress=true] - Apply suppressions from .config-audit-ignore
|
||||
* @param {boolean} [opts.filterFixtures=true] - Exclude findings from test/example paths
|
||||
* @returns {Promise<object>} Full envelope with all results
|
||||
*/
|
||||
// Exported for testing
|
||||
export { isFixturePath, FIXTURE_DIR_NAMES };
|
||||
|
||||
export async function runAllScanners(targetPath, opts = {}) {
|
||||
const start = Date.now();
|
||||
const resolvedPath = resolve(targetPath);
|
||||
|
||||
// Shared file discovery — scanners reuse this
|
||||
let discovery;
|
||||
if (opts.fullMachine) {
|
||||
const roots = await discoverFullMachinePaths();
|
||||
discovery = await discoverConfigFilesMulti(roots);
|
||||
} else {
|
||||
discovery = await discoverConfigFiles(resolvedPath, {
|
||||
includeGlobal: opts.includeGlobal || false,
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const scanner of SCANNERS) {
|
||||
resetCounter();
|
||||
const scanStart = Date.now();
|
||||
try {
|
||||
const result = await scanner.fn(resolvedPath, discovery);
|
||||
results.push(result);
|
||||
const count = result.findings.length;
|
||||
process.stderr.write(` [${scanner.name}] ${scanner.label}: ${count} finding(s) (${Date.now() - scanStart}ms)\n`);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
scanner: scanner.name,
|
||||
status: 'error',
|
||||
files_scanned: 0,
|
||||
duration_ms: Date.now() - scanStart,
|
||||
findings: [],
|
||||
counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
|
||||
error: err.message,
|
||||
});
|
||||
process.stderr.write(` [${scanner.name}] ${scanner.label}: ERROR — ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter findings from test fixtures / examples (unless disabled)
|
||||
const shouldFilterFixtures = opts.filterFixtures !== false;
|
||||
let fixtureFindings = [];
|
||||
|
||||
if (shouldFilterFixtures) {
|
||||
for (const result of results) {
|
||||
const active = [];
|
||||
const fixture = [];
|
||||
for (const f of result.findings) {
|
||||
if (isFixturePath(f, resolvedPath)) {
|
||||
fixture.push(f);
|
||||
} else {
|
||||
active.push(f);
|
||||
}
|
||||
}
|
||||
if (fixture.length > 0) {
|
||||
fixtureFindings.push(...fixture);
|
||||
result.findings = active;
|
||||
result.counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
for (const f of active) {
|
||||
if (result.counts[f.severity] !== undefined) result.counts[f.severity]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fixtureFindings.length > 0) {
|
||||
process.stderr.write(` ${fixtureFindings.length} finding(s) from test fixtures excluded\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply suppressions (unless disabled)
|
||||
const shouldSuppress = opts.suppress !== false;
|
||||
let suppressedFindings = [];
|
||||
|
||||
if (shouldSuppress) {
|
||||
const { suppressions } = await loadSuppressions(resolvedPath);
|
||||
if (suppressions.length > 0) {
|
||||
for (const result of results) {
|
||||
const { active, suppressed } = applySuppressions(result.findings, suppressions);
|
||||
suppressedFindings.push(...suppressed);
|
||||
result.findings = active;
|
||||
// Recalculate counts
|
||||
result.counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||
for (const f of active) {
|
||||
if (result.counts[f.severity] !== undefined) result.counts[f.severity]++;
|
||||
}
|
||||
}
|
||||
if (suppressedFindings.length > 0) {
|
||||
process.stderr.write(` ${formatSuppressionSummary(suppressedFindings)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalMs = Date.now() - start;
|
||||
const env = envelope(resolvedPath, results, totalMs);
|
||||
if (fixtureFindings.length > 0) {
|
||||
env.fixture_findings = fixtureFindings;
|
||||
}
|
||||
if (suppressedFindings.length > 0) {
|
||||
env.suppressed_findings = suppressedFindings;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// --- CLI entry point ---
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let targetPath = '.';
|
||||
let outputFile = null;
|
||||
let saveBaseline = false;
|
||||
let baselinePath = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--output-file' && args[i + 1]) {
|
||||
outputFile = args[++i];
|
||||
} else if (args[i] === '--save-baseline') {
|
||||
saveBaseline = true;
|
||||
} else if (args[i] === '--baseline' && args[i + 1]) {
|
||||
baselinePath = args[++i];
|
||||
} else if (args[i] === '--global') {
|
||||
// handled below
|
||||
} else if (args[i] === '--full-machine') {
|
||||
// handled below
|
||||
} else if (args[i] === '--no-suppress') {
|
||||
// handled below
|
||||
} else if (args[i] === '--include-fixtures') {
|
||||
// handled below
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
targetPath = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
const includeGlobal = args.includes('--global');
|
||||
const fullMachine = args.includes('--full-machine');
|
||||
const suppress = !args.includes('--no-suppress');
|
||||
const filterFixtures = !args.includes('--include-fixtures');
|
||||
|
||||
process.stderr.write(`Config-Audit Scanner v2.2.0\n`);
|
||||
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
|
||||
process.stderr.write(`Scope: ${fullMachine ? 'full-machine' : includeGlobal ? 'global' : 'project'}\n`);
|
||||
process.stderr.write(`Fixtures: ${filterFixtures ? 'excluded' : 'included'}\n\n`);
|
||||
|
||||
const result = await runAllScanners(targetPath, { includeGlobal, fullMachine, suppress, filterFixtures });
|
||||
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
} else {
|
||||
process.stdout.write(json + '\n');
|
||||
}
|
||||
|
||||
if (saveBaseline) {
|
||||
const bPath = baselinePath || resolve(targetPath, '.config-audit-baseline.json');
|
||||
await writeFile(bPath, json, 'utf-8');
|
||||
process.stderr.write(`Baseline saved to ${bPath}\n`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const agg = result.aggregate;
|
||||
process.stderr.write(`\n--- Summary ---\n`);
|
||||
process.stderr.write(`Findings: ${agg.total_findings} (C:${agg.counts.critical} H:${agg.counts.high} M:${agg.counts.medium} L:${agg.counts.low} I:${agg.counts.info})\n`);
|
||||
process.stderr.write(`Risk: ${agg.risk_score}/100 (${agg.risk_band})\n`);
|
||||
process.stderr.write(`Verdict: ${agg.verdict}\n`);
|
||||
|
||||
// Exit code
|
||||
if (agg.verdict === 'FAIL') process.exit(2);
|
||||
if (agg.verdict === 'WARNING') process.exit(1);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Only run CLI if invoked directly
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
||||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
178
plugins/config-audit/scanners/self-audit.mjs
Normal file
178
plugins/config-audit/scanners/self-audit.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Config-Audit Self-Audit
|
||||
* Runs the plugin's own scanners on its own configuration.
|
||||
* CLI: node self-audit.mjs [--json] [--fix]
|
||||
* Exit codes: 0=PASS (no critical/high), 1=WARN (high findings), 2=FAIL (critical findings)
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { scan as scanPluginHealth } from './plugin-health-scanner.mjs';
|
||||
import { scoreByArea } from './lib/scoring.mjs';
|
||||
import { gradeFromPassRate } from './lib/severity.mjs';
|
||||
import { loadSuppressions, applySuppressions } from './lib/suppression.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = resolve(__dirname, '..');
|
||||
|
||||
/**
|
||||
* Run self-audit on this plugin.
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.fix=false] - Run fix-engine on auto-fixable findings
|
||||
* @returns {Promise<object>} Combined result
|
||||
*/
|
||||
export async function runSelfAudit(opts = {}) {
|
||||
const pluginDir = PLUGIN_ROOT;
|
||||
|
||||
// 1. Run all config scanners on plugin root
|
||||
// Fixture filtering is handled automatically by runAllScanners (filterFixtures defaults to true)
|
||||
const configEnvelope = await runAllScanners(pluginDir);
|
||||
|
||||
// 2. Run plugin health scanner + apply suppressions
|
||||
const pluginHealthResult = await scanPluginHealth(pluginDir);
|
||||
const { suppressions } = await loadSuppressions(pluginDir);
|
||||
if (suppressions.length > 0) {
|
||||
const { active, suppressed } = applySuppressions(pluginHealthResult.findings, suppressions);
|
||||
pluginHealthResult.findings = active;
|
||||
pluginHealthResult.suppressedFindings = suppressed;
|
||||
}
|
||||
|
||||
// 3. Score config quality
|
||||
const areaScores = scoreByArea(configEnvelope.scanners);
|
||||
const avgScore = areaScores.areas.length > 0
|
||||
? Math.round(areaScores.areas.reduce((s, a) => s + a.score, 0) / areaScores.areas.length)
|
||||
: 0;
|
||||
const configGrade = gradeFromPassRate(avgScore);
|
||||
|
||||
// 4. Score plugin health
|
||||
const pluginIssueCount = pluginHealthResult.findings.length;
|
||||
const pluginScore = Math.max(0, 100 - pluginIssueCount * 10);
|
||||
const pluginGrade = gradeFromPassRate(pluginScore);
|
||||
|
||||
// 5. Determine overall result
|
||||
const allFindings = [
|
||||
...configEnvelope.scanners.flatMap(s => s.findings),
|
||||
...pluginHealthResult.findings,
|
||||
];
|
||||
|
||||
const hasCritical = allFindings.some(f => f.severity === 'critical');
|
||||
const hasHigh = allFindings.some(f => f.severity === 'high');
|
||||
let exitCode = 0;
|
||||
let verdict = 'PASS';
|
||||
if (hasCritical) { exitCode = 2; verdict = 'FAIL'; }
|
||||
else if (hasHigh) { exitCode = 1; verdict = 'WARN'; }
|
||||
|
||||
// 6. Optionally fix
|
||||
let fixResult = null;
|
||||
if (opts.fix && allFindings.some(f => f.autoFixable)) {
|
||||
try {
|
||||
const { planFixes, applyFixes } = await import('./fix-engine.mjs');
|
||||
const plan = planFixes(configEnvelope);
|
||||
if (plan.length > 0) {
|
||||
fixResult = await applyFixes(plan);
|
||||
}
|
||||
} catch {
|
||||
// Fix engine unavailable or failed — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pluginDir,
|
||||
configGrade,
|
||||
configScore: avgScore,
|
||||
pluginGrade,
|
||||
pluginScore,
|
||||
configEnvelope,
|
||||
pluginHealthResult,
|
||||
allFindings,
|
||||
exitCode,
|
||||
verdict,
|
||||
fixResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format self-audit result for terminal display.
|
||||
* @param {object} result - From runSelfAudit()
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSelfAudit(result) {
|
||||
const lines = [];
|
||||
lines.push('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
|
||||
lines.push(' Config-Audit Self-Audit');
|
||||
lines.push('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
|
||||
lines.push('');
|
||||
lines.push(` Plugin health: ${result.pluginGrade} (${result.pluginScore})`);
|
||||
lines.push(` Config quality: ${result.configGrade} (${result.configScore})`);
|
||||
lines.push('');
|
||||
|
||||
// Issues summary
|
||||
const nonInfo = result.allFindings.filter(f => f.severity !== 'info');
|
||||
if (nonInfo.length > 0) {
|
||||
lines.push(` Issues (${nonInfo.length}):`);
|
||||
for (const f of nonInfo.slice(0, 10)) {
|
||||
lines.push(` - [${f.severity}] ${f.title}`);
|
||||
}
|
||||
if (nonInfo.length > 10) {
|
||||
lines.push(` ...and ${nonInfo.length - 10} more`);
|
||||
}
|
||||
} else {
|
||||
lines.push(' Issues (0)');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Fix results
|
||||
if (result.fixResult) {
|
||||
const applied = result.fixResult.filter(r => r.status === 'applied').length;
|
||||
lines.push(` Auto-fix: ${applied} fix(es) applied`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Verdict
|
||||
if (result.verdict === 'PASS') {
|
||||
lines.push(' Self-audit: PASS');
|
||||
lines.push(' (No critical or high findings)');
|
||||
} else if (result.verdict === 'WARN') {
|
||||
lines.push(' Self-audit: WARN');
|
||||
lines.push(' (High-severity findings detected)');
|
||||
} else {
|
||||
lines.push(' Self-audit: FAIL');
|
||||
lines.push(' (Critical findings detected)');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// --- CLI entry point ---
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonMode = args.includes('--json');
|
||||
const fixMode = args.includes('--fix');
|
||||
|
||||
const result = await runSelfAudit({ fix: fixMode });
|
||||
|
||||
if (jsonMode) {
|
||||
const json = JSON.stringify(result, null, 2) + '\n';
|
||||
await new Promise(resolve => process.stdout.write(json, resolve));
|
||||
} else {
|
||||
process.stderr.write('\n' + formatSelfAudit(result) + '\n');
|
||||
}
|
||||
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
224
plugins/config-audit/scanners/settings-validator.mjs
Normal file
224
plugins/config-audit/scanners/settings-validator.mjs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* SET Scanner — Settings.json Validator
|
||||
* Validates schema, detects unknown/deprecated keys, type mismatches.
|
||||
* Finding IDs: CA-SET-NNN
|
||||
*/
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseJson } from './lib/yaml-parser.mjs';
|
||||
import { extractKeys } from './lib/string-utils.mjs';
|
||||
|
||||
const SCANNER = 'SET';
|
||||
|
||||
/** Known top-level settings.json keys (as of April 2026) */
|
||||
const KNOWN_KEYS = new Set([
|
||||
'agent', 'allowedChannelPlugins', 'allowedHttpHookUrls', 'allowedMcpServers',
|
||||
'allowManagedHooksOnly', 'allowManagedMcpServersOnly', 'allowManagedPermissionRulesOnly',
|
||||
'alwaysThinkingEnabled', 'apiKeyHelper', 'attribution', 'autoMemoryDirectory',
|
||||
'autoMemoryEnabled', 'autoMode', 'autoUpdatesChannel', 'availableModels',
|
||||
'awsAuthRefresh', 'awsCredentialExport', 'blockedMarketplaces', 'channelsEnabled',
|
||||
'cleanupPeriodDays', 'claudeMdExcludes', 'companyAnnouncements', 'defaultShell',
|
||||
'deniedMcpServers', 'disableAllHooks', 'disableAutoMode', 'disableDeepLinkRegistration',
|
||||
'disabledMcpjsonServers', 'effortLevel', 'enableAllProjectMcpServers',
|
||||
'enabledMcpjsonServers', 'enabledPlugins', 'env', 'extraKnownMarketplaces',
|
||||
'fastModePerSessionOptIn', 'feedbackSurveyRate', 'fileSuggestion',
|
||||
'forceLoginMethod', 'forceLoginOrgUUID', 'hooks', 'httpHookAllowedEnvVars',
|
||||
'includeCoAuthoredBy', 'includeGitInstructions', 'language', 'model',
|
||||
'modelOverrides', 'otelHeadersHelper', 'outputStyle', 'permissions',
|
||||
'plansDirectory', 'pluginTrustMessage', 'prefersReducedMotion',
|
||||
'respectGitignore', 'showClearContextOnPlanAccept', 'showThinkingSummaries',
|
||||
'spinnerTipsEnabled', 'spinnerTipsOverride', 'spinnerVerbs', 'statusLine',
|
||||
'strictKnownMarketplaces', 'useAutoModeDuringPlan', 'voiceEnabled',
|
||||
'worktree', '$schema',
|
||||
]);
|
||||
|
||||
/** Deprecated keys with migration info */
|
||||
const DEPRECATED_KEYS = new Map([
|
||||
['includeCoAuthoredBy', 'Use "attribution" instead'],
|
||||
]);
|
||||
|
||||
/** Keys that require specific types */
|
||||
const TYPE_CHECKS = new Map([
|
||||
['alwaysThinkingEnabled', 'boolean'],
|
||||
['autoMemoryEnabled', 'boolean'],
|
||||
['channelsEnabled', 'boolean'],
|
||||
['cleanupPeriodDays', 'number'],
|
||||
['disableAllHooks', 'boolean'],
|
||||
['effortLevel', 'string'],
|
||||
['enableAllProjectMcpServers', 'boolean'],
|
||||
['fastModePerSessionOptIn', 'boolean'],
|
||||
['feedbackSurveyRate', 'number'],
|
||||
['includeGitInstructions', 'boolean'],
|
||||
['language', 'string'],
|
||||
['model', 'string'],
|
||||
['outputStyle', 'string'],
|
||||
['prefersReducedMotion', 'boolean'],
|
||||
['respectGitignore', 'boolean'],
|
||||
['showThinkingSummaries', 'boolean'],
|
||||
['spinnerTipsEnabled', 'boolean'],
|
||||
['voiceEnabled', 'boolean'],
|
||||
]);
|
||||
|
||||
/** Valid effortLevel values */
|
||||
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']);
|
||||
|
||||
/**
|
||||
* Scan all settings.json files discovered.
|
||||
* @param {string} targetPath
|
||||
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
const settingsFiles = discovery.files.filter(f => f.type === 'settings-json');
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
if (settingsFiles.length === 0) {
|
||||
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
for (const file of settingsFiles) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (!content) continue;
|
||||
filesScanned++;
|
||||
|
||||
const parsed = parseJson(content);
|
||||
if (parsed === null) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in settings file',
|
||||
description: `${file.relPath} contains invalid JSON and will be ignored by Claude Code.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Fix JSON syntax errors. Use a JSON validator.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for unknown keys
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (!KNOWN_KEYS.has(key)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unknown settings key',
|
||||
description: `${file.relPath}: "${key}" is not a recognized settings.json key. It will be silently ignored.`,
|
||||
file: file.absPath,
|
||||
evidence: key,
|
||||
recommendation: 'Check spelling. See https://json.schemastore.org/claude-code-settings.json for valid keys.',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for deprecated keys
|
||||
for (const [key, migration] of DEPRECATED_KEYS) {
|
||||
if (parsed[key] !== undefined) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Deprecated settings key',
|
||||
description: `${file.relPath}: "${key}" is deprecated. ${migration}`,
|
||||
file: file.absPath,
|
||||
evidence: `${key}: ${JSON.stringify(parsed[key])}`,
|
||||
recommendation: migration,
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Type validation
|
||||
for (const [key, expectedType] of TYPE_CHECKS) {
|
||||
if (parsed[key] !== undefined && typeof parsed[key] !== expectedType) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.high,
|
||||
title: 'Type mismatch in settings',
|
||||
description: `${file.relPath}: "${key}" should be ${expectedType}, got ${typeof parsed[key]}.`,
|
||||
file: file.absPath,
|
||||
evidence: `${key}: ${JSON.stringify(parsed[key])} (${typeof parsed[key]})`,
|
||||
recommendation: `Change "${key}" to a ${expectedType} value.`,
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// effortLevel value check
|
||||
if (parsed.effortLevel && !VALID_EFFORT_LEVELS.has(parsed.effortLevel)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Invalid effortLevel value',
|
||||
description: `${file.relPath}: effortLevel "${parsed.effortLevel}" is not valid.`,
|
||||
file: file.absPath,
|
||||
evidence: `effortLevel: "${parsed.effortLevel}"`,
|
||||
recommendation: `Use one of: ${[...VALID_EFFORT_LEVELS].join(', ')}`,
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Missing $schema hint
|
||||
if (!parsed.$schema) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
title: 'Missing $schema reference',
|
||||
description: `${file.relPath} lacks a $schema reference. Adding one enables autocomplete in VS Code/Cursor.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add: "$schema": "https://json.schemastore.org/claude-code-settings.json"',
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Permissions checks
|
||||
if (parsed.permissions) {
|
||||
const perms = parsed.permissions;
|
||||
|
||||
if (!perms.deny || (Array.isArray(perms.deny) && perms.deny.length === 0)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.medium,
|
||||
title: 'No deny rules configured',
|
||||
description: `${file.relPath}: No permission deny rules. Claude can access all files including .env and secrets.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add deny rules for sensitive files: "deny": ["Read(./.env)", "Read(./secrets/**)"]',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!perms.allow || (Array.isArray(perms.allow) && perms.allow.length === 0)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
title: 'No allow rules configured',
|
||||
description: `${file.relPath}: No permission allow rules. This means frequent permission prompts for common operations.`,
|
||||
file: file.absPath,
|
||||
recommendation: 'Add allow rules for common tools: "allow": ["Bash(npm run *)", "Read(src/**)"]',
|
||||
autoFixable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// hooks checks (basic — detailed in hook-validator)
|
||||
if (parsed.hooks) {
|
||||
if (Array.isArray(parsed.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Hooks configured as array instead of object',
|
||||
description: `${file.relPath}: "hooks" must be an object with event keys, not an array. All hooks will be ignored.`,
|
||||
file: file.absPath,
|
||||
evidence: '"hooks": [...]',
|
||||
recommendation: 'Change to object format: "hooks": { "PreToolUse": [...] }',
|
||||
autoFixable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue