238 lines
8.4 KiB
JavaScript
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);
|
|
}
|