ktg-plugin-marketplace/plugins/config-audit/scanners/fix-engine.mjs

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 };