auto-claude: subtask-2-1 - Add suppression filtering to render_report.mjs

Implements suppression filtering logic for security audit findings:
- Import loadSuppressionConfig for config loading
- Add --config CLI argument for custom config paths
- Create extractSkillName() to extract skill names from findings (tries multiple fields)
- Create filterFindings() to split findings into active/suppressed
- Match suppressions by BOTH checkId AND skill name (exact match required)
- Attach suppression metadata (reason, suppressedAt) to suppressed findings
- Modify render() to accept suppressedFindings parameter
- Apply filtering in main execution before rendering

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
David Abutbul
2026-02-16 15:43:41 +02:00
parent f76cdd22a9
commit 9685db79d3
@@ -3,10 +3,11 @@
* Render a human-readable security audit report from openclaw JSON.
*
* Usage:
* node render_report.mjs --audit audit.json --deep deep.json --label "host label"
* node render_report.mjs --audit audit.json --deep deep.json --label "host label" [--config config.json]
*/
import fs from "node:fs";
import { loadSuppressionConfig } from "./load_suppression_config.mjs";
function readJsonSafe(p, label) {
if (!p) return { findings: [], summary: {}, error: `${label} missing` };
@@ -29,6 +30,84 @@ function pickFindings(report) {
};
}
/**
* Extract skill name from a finding object.
* Tries multiple fields in priority order.
*
* @param {object} finding - The finding object
* @returns {string|null} - The skill name or null if not found
*/
function extractSkillName(finding) {
if (!finding) return null;
// Try common fields where skill name might be stored
if (finding.skill) return String(finding.skill).trim();
if (finding.skillName) return String(finding.skillName).trim();
if (finding.target) return String(finding.target).trim();
// Attempt to extract from path (e.g., "skills/my-skill/...")
if (finding.path && typeof finding.path === "string") {
const pathMatch = finding.path.match(/skills\/([^\/]+)/);
if (pathMatch) return pathMatch[1];
}
// Attempt to extract from title (e.g., "[my-skill] some issue")
if (finding.title && typeof finding.title === "string") {
const titleMatch = finding.title.match(/^\[([^\]]+)\]/);
if (titleMatch) return titleMatch[1];
}
return null;
}
/**
* Filter findings into active and suppressed based on suppression config.
* Matches require BOTH checkId AND skill name to match (exact match).
*
* @param {Array} findings - Array of finding objects
* @param {Array} suppressions - Array of suppression rules
* @returns {{active: Array, suppressed: Array}}
*/
function filterFindings(findings, suppressions) {
if (!Array.isArray(findings)) {
return { active: [], suppressed: [] };
}
if (!Array.isArray(suppressions) || suppressions.length === 0) {
return { active: findings, suppressed: [] };
}
const active = [];
const suppressed = [];
for (const finding of findings) {
const checkId = finding?.checkId ?? "";
const skillName = extractSkillName(finding);
// Check if this finding matches any suppression rule
const isSuppressed = suppressions.some((rule) => {
// BOTH checkId AND skill must match (exact match, case-sensitive)
return rule.checkId === checkId && rule.skill === skillName;
});
if (isSuppressed) {
// Find the matching rule to attach suppression metadata
const matchingRule = suppressions.find(
(rule) => rule.checkId === checkId && rule.skill === skillName
);
suppressed.push({
...finding,
suppressionReason: matchingRule?.reason,
suppressedAt: matchingRule?.suppressedAt,
});
} else {
active.push(finding);
}
}
return { active, suppressed };
}
function lineForFinding(f) {
const id = f?.checkId ?? "(no-checkId)";
const title = f?.title ?? "(no-title)";
@@ -37,7 +116,7 @@ function lineForFinding(f) {
return `- ${id} ${title}${fixLine ? `\n ${fixLine}` : ""}`;
}
function render({ audit, deep, label }) {
function render({ audit, deep, label, suppressedFindings = [] }) {
const now = new Date().toISOString();
const a = pickFindings(audit);
const d = pickFindings(deep);
@@ -94,12 +173,41 @@ function parseArgs(argv) {
if (a === "--audit") out.audit = argv[++i];
else if (a === "--deep") out.deep = argv[++i];
else if (a === "--label") out.label = argv[++i];
else if (a === "--config") out.config = argv[++i];
}
return out;
}
// Main execution
const args = parseArgs(process.argv.slice(2));
// Load suppression config (async)
const suppressionConfig = await loadSuppressionConfig(args.config || null);
const suppressions = suppressionConfig.suppressions || [];
// Read audit results
const audit = readJsonSafe(args.audit, "audit");
const deep = readJsonSafe(args.deep, "deep");
const report = render({ audit, deep, label: args.label });
// Apply suppression filtering to findings
const allFindings = [...(audit.findings || []), ...(deep.findings || [])];
const { active: activeFindings, suppressed: suppressedFindings } = filterFindings(
allFindings,
suppressions
);
// Replace findings in audit/deep with filtered active findings
if (audit.findings) {
audit.findings = activeFindings.filter((f) =>
(audit.findings || []).some((orig) => orig === f || JSON.stringify(orig) === JSON.stringify(f))
);
}
if (deep.findings) {
deep.findings = activeFindings.filter((f) =>
(deep.findings || []).some((orig) => orig === f || JSON.stringify(orig) === JSON.stringify(f))
);
}
// Render report with suppressed findings
const report = render({ audit, deep, label: args.label, suppressedFindings });
process.stdout.write(report + "\n");