#!/usr/bin/env node /** * Config-Audit Scan Orchestrator * Runs all registered scanners sequentially, collects findings, outputs JSON envelope. * Usage: node scan-orchestrator.mjs [--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'; import { scan as scanTokenHotspots } from './token-hotspots.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' }, { name: 'TOK', fn: scanTokenHotspots, label: 'Token Hotspots' }, ]; /** * 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} 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); }); }