ktg-plugin-marketplace/plugins/config-audit/scanners/conflict-detector.mjs

238 lines
8.4 KiB
JavaScript

/**
* 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);
}