ktg-plugin-marketplace/plugins/config-audit/scanners/lib/suppression.mjs

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(', ')})`;
}