154 lines
4.3 KiB
JavaScript
154 lines
4.3 KiB
JavaScript
/**
|
|
* 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(', ')})`;
|
|
}
|