/** * Suppression engine for config-audit. * Lets users suppress known false positives via .config-audit-ignore files. * Supports exact IDs (CA-CML-001) and glob patterns (CA-SET-*). * Zero external dependencies. */ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { homedir } from 'node:os'; /** * Load suppressions from .config-audit-ignore files. * Searches targetPath first, then ~/.claude/config-audit/. * Project-level file takes precedence (loaded first). * @param {string} targetPath - Project root to search * @returns {Promise<{ suppressions: Array<{ pattern: string, comment: string }>, source: string }>} */ export async function loadSuppressions(targetPath) { const sources = [ { path: join(targetPath, '.config-audit-ignore'), label: 'project' }, { path: join(homedir(), '.config-audit', '.config-audit-ignore'), label: 'global' }, ]; for (const src of sources) { try { const content = await readFile(src.path, 'utf-8'); const suppressions = parseIgnoreFile(content); return { suppressions, source: src.label }; } catch { // File doesn't exist — try next } } return { suppressions: [], source: 'none' }; } /** * Parse a .config-audit-ignore file into suppression entries. * @param {string} content - File content * @returns {Array<{ pattern: string, comment: string }>} */ export function parseIgnoreFile(content) { const suppressions = []; for (const rawLine of content.split('\n')) { const line = rawLine.trim(); // Skip empty lines and comment-only lines if (!line || line.startsWith('#')) continue; // Split on first # for inline comment const hashIdx = line.indexOf('#'); let pattern, comment; if (hashIdx > 0) { pattern = line.slice(0, hashIdx).trim(); comment = line.slice(hashIdx + 1).trim(); } else { pattern = line; comment = ''; } // Validate pattern looks like a finding ID or glob if (/^CA-[A-Z]{2,4}[-*\d]+/.test(pattern) || /^CA-[A-Z]{2,4}-\*$/.test(pattern)) { suppressions.push({ pattern, comment }); } } return suppressions; } /** * Apply suppressions to a findings array. * @param {object[]} findings - Array of finding objects with .id * @param {Array<{ pattern: string, comment: string }>} suppressions * @returns {{ active: object[], suppressed: object[] }} */ export function applySuppressions(findings, suppressions) { if (!suppressions || suppressions.length === 0) { return { active: [...findings], suppressed: [] }; } const active = []; const suppressed = []; for (const f of findings) { if (isMatchedByAny(f.id, suppressions)) { suppressed.push(f); } else { active.push(f); } } return { active, suppressed }; } /** * Check if a finding ID matches any suppression pattern. * @param {string} id - Finding ID (e.g. CA-CML-001) * @param {Array<{ pattern: string }>} suppressions * @returns {boolean} */ function isMatchedByAny(id, suppressions) { for (const s of suppressions) { if (matchPattern(id, s.pattern)) return true; } return false; } /** * Match a finding ID against a suppression pattern. * Supports exact match and glob-style CA-XXX-* patterns. * @param {string} id - e.g. "CA-CML-001" * @param {string} pattern - e.g. "CA-CML-001" or "CA-CML-*" * @returns {boolean} */ function matchPattern(id, pattern) { // Exact match if (id === pattern) return true; // Glob: CA-XXX-* matches any CA-XXX-NNN if (pattern.endsWith('-*')) { const prefix = pattern.slice(0, -1); // "CA-XXX-" return id.startsWith(prefix); } return false; } /** * Format a human-readable suppression summary line. * @param {object[]} suppressed - Array of suppressed findings * @returns {string} */ export function formatSuppressionSummary(suppressed) { if (!suppressed || suppressed.length === 0) { return '0 findings suppressed'; } // Group by scanner prefix pattern const groups = new Map(); for (const f of suppressed) { // Extract prefix: CA-CML-001 → CA-CML const prefix = f.id.replace(/-\d+$/, ''); groups.set(prefix, (groups.get(prefix) || 0) + 1); } const parts = []; for (const [prefix, count] of groups) { parts.push(`${count} \u00d7 ${prefix}-*`); } return `${suppressed.length} finding(s) suppressed (${parts.join(', ')})`; }