666 lines
19 KiB
JavaScript
666 lines
19 KiB
JavaScript
/**
|
|
* 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 };
|