mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
63de5ce08d
* auto-claude: subtask-1-1 - Create config loading utility with multi-path fallback Created load_suppression_config.mjs with: - Multi-path fallback: ~/.openclaw/security-audit.json -> .clawsec/allowlist.json - Environment variable support (OPENCLAW_AUDIT_CONFIG) - Custom path support via CLI argument - Schema validation (checkId, skill, reason, suppressedAt required) - Malformed JSON error handling - Graceful fallback to empty suppressions when no config exists - ISO 8601 date format validation with warnings Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-2 - Create example config file template - Added security-audit-config.example.json with two suppression examples - Included examples for clawsec-suite and openclaw-audit-watchdog - Created comprehensive README.md explaining configuration format - All required fields documented (checkId, skill, reason, suppressedAt) - ISO 8601 date format demonstrated - JSON validated successfully * auto-claude: subtask-1-3 - Add unit tests for config loading Added comprehensive unit tests for suppression config loading: - Valid config with all required fields - Malformed date warning (non-blocking) - Missing required field validation - Malformed JSON error handling - File not found graceful fallback - Custom path priority - Environment variable override - Missing/empty suppressions array handling All 10 tests passing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * 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> * auto-claude: subtask-2-2 - Add INFO-SUPPRESSED section to report output - Added lineForSuppressedFinding() to format suppressed findings - Added INFO-SUPPRESSED section showing suppressed findings with reason and date - Suppressed findings are not counted in summary (already filtered) - Follows existing code patterns for report sections Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-1 - Add --config flag to run_audit_and_format.sh - Added --config flag to accept path to config file - Added --help flag with usage documentation - Config flag is passed to openclaw audit commands when provided - Follows existing pattern for --label flag * auto-claude: subtask-4-1 - Create integration tests for render_report with suppressions Created comprehensive integration tests covering: - Suppressed findings appear in INFO-SUPPRESSED section - Active findings appear in CRITICAL/WARN section - Summary counts exclude suppressed findings - Backward compatibility (no config) - Partial matches don't suppress (checkId or skill alone) - Multiple suppressions work correctly - Skill name extraction from path field - Skill name extraction from title field - Empty suppressions array behaves like no config Bug fix in render_report.mjs: - Summary counts now recalculated after filtering suppressed findings - Previously summary showed original counts instead of filtered counts All 10 tests passing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-4-2 - Manual E2E test with real openclaw audit - Fixed run_audit_and_format.sh to pass --config flag to render_report.mjs - Enhanced lineForFinding() to display skill names for better clarity - Enhanced lineForSuppressedFinding() to display skill names consistently - Created comprehensive E2E test documentation in E2E-TEST-RESULTS.md - All E2E verification points passed: * Config loading from custom paths * Suppression matching by checkId + skill name * INFO-SUPPRESSED section display * Suppression reason and date display * Summary count accuracy (excludes suppressed findings) * Non-suppressed findings preservation * Skill name display in all findings - All integration tests still passing (10/10) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-5-1 - Update README.md with suppression feature * auto-claude: subtask-5-2 - Update SKILL.md with usage examples * - Add backslash escaping before quote escaping in oneline() function - Prevents incomplete string escaping vulnerability - Resolves CodeQL alert: https://github.com/prompt-security/clawsec/security/code-scanning/16 * Fix regex in extractSkillName function and simplify error handling in suppression config tests * Enhance suppression mechanism in OpenClaw Audit Watchdog - Updated README.md to clarify suppression configuration and activation requirements. - Improved SKILL.md with examples for suppressing known findings. - Refactored load_suppression_config.mjs to implement opt-in gating for suppressions. - Modified render_report.mjs to support suppression flag in report generation. - Enhanced run_audit_and_format.sh and runner.sh scripts to accept --enable-suppressions flag. - Added test cases for suppression configuration, including validation for enabledFor sentinel and opt-in behavior. - Introduced new test files for empty and invalid suppression configurations. * Fix type assertion for checksums file entries in Checksums component * Update ESLint configuration and dependencies to pin @eslint/js to version 9.28.0 * Update CHANGELOG.md for advisory suppression module and OpenClaw Audit Watchdog enhancements * Refactor finding comparison logic in render_report.mjs to simplify equality checks * chore(clawsec-suite): bump version to 0.1.2 * chore(openclaw-audit-watchdog): bump version to 0.1.0 * Remove suppressed matches tracking from state to prevent re-evaluation alerts --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
249 lines
7.9 KiB
JavaScript
Executable File
249 lines
7.9 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Render a human-readable security audit report from openclaw JSON.
|
|
*
|
|
* Usage:
|
|
* node render_report.mjs --audit audit.json --deep deep.json --label "host label" [--enable-suppressions] [--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` };
|
|
try {
|
|
const s = fs.readFileSync(p, "utf8");
|
|
return JSON.parse(s);
|
|
} catch (e) {
|
|
return { findings: [], summary: {}, error: `${label} parse failed: ${e?.message || String(e)}` };
|
|
}
|
|
}
|
|
|
|
function pickFindings(report) {
|
|
const findings = Array.isArray(report?.findings) ? report.findings : [];
|
|
const bySev = (sev) => findings.filter((f) => f?.severity === sev);
|
|
return {
|
|
critical: bySev("critical"),
|
|
warn: bySev("warn"),
|
|
info: bySev("info"),
|
|
summary: report?.summary ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 skillName = extractSkillName(f);
|
|
const skillLabel = skillName ? `[${skillName}] ` : "";
|
|
const title = f?.title ?? "(no-title)";
|
|
const fix = (f?.remediation ?? "").trim();
|
|
const fixLine = fix ? `Fix: ${fix}` : "";
|
|
return `- ${id} ${skillLabel}${title}${fixLine ? `\n ${fixLine}` : ""}`;
|
|
}
|
|
|
|
function lineForSuppressedFinding(f) {
|
|
const id = f?.checkId ?? "(no-checkId)";
|
|
const skillName = extractSkillName(f) ?? "(unknown-skill)";
|
|
const title = f?.title ?? "(no-title)";
|
|
const reason = f?.suppressionReason ?? "(no reason)";
|
|
const date = f?.suppressedAt ?? "(no date)";
|
|
return `- ${id} [${skillName}] ${title}\n Suppressed: ${reason} (${date})`;
|
|
}
|
|
|
|
function render({ audit, deep, label, suppressedFindings = [] }) {
|
|
const now = new Date().toISOString();
|
|
const a = pickFindings(audit);
|
|
const d = pickFindings(deep);
|
|
|
|
const summary = a.summary || d.summary || { critical: 0, warn: 0, info: 0 };
|
|
|
|
const lines = [];
|
|
lines.push(`openclaw security audit report${label ? ` -- ${label}` : ""}`);
|
|
lines.push(`Time: ${now}`);
|
|
lines.push(`Summary: ${summary.critical ?? 0} critical · ${summary.warn ?? 0} warn · ${summary.info ?? 0} info`);
|
|
|
|
const top = [];
|
|
top.push(...a.critical, ...a.warn);
|
|
const seen = new Set();
|
|
const deduped = [];
|
|
for (const f of top) {
|
|
const key = `${f?.severity}:${f?.checkId}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
deduped.push(f);
|
|
}
|
|
|
|
if (deduped.length) {
|
|
lines.push("");
|
|
lines.push("Findings (critical/warn):");
|
|
for (const f of deduped.slice(0, 25)) lines.push(lineForFinding(f));
|
|
if (deduped.length > 25) lines.push(`…${deduped.length - 25} more`);
|
|
}
|
|
|
|
// Surface deep probe failure if present
|
|
const deepProbe = Array.isArray(deep?.findings)
|
|
? deep.findings.find((f) => f?.checkId === "gateway.probe_failed")
|
|
: null;
|
|
if (deepProbe) {
|
|
lines.push("");
|
|
lines.push("Deep probe:");
|
|
lines.push(lineForFinding(deepProbe));
|
|
}
|
|
|
|
const errors = [audit?.error, deep?.error].filter(Boolean);
|
|
if (errors.length) {
|
|
lines.push("");
|
|
lines.push("Errors:");
|
|
for (const e of errors) lines.push(`- ${e}`);
|
|
}
|
|
|
|
// Show suppressed findings
|
|
if (suppressedFindings.length) {
|
|
lines.push("");
|
|
lines.push("INFO-SUPPRESSED:");
|
|
for (const f of suppressedFindings) {
|
|
lines.push(lineForSuppressedFinding(f));
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
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];
|
|
else if (a === "--enable-suppressions") out.enableSuppressions = true;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Main execution
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
// Load suppression config (requires explicit opt-in)
|
|
const suppressionConfig = await loadSuppressionConfig(args.config || null, {
|
|
enabled: !!args.enableSuppressions,
|
|
});
|
|
const suppressions = suppressionConfig.suppressions || [];
|
|
|
|
// Read audit results
|
|
const audit = readJsonSafe(args.audit, "audit");
|
|
const deep = readJsonSafe(args.deep, "deep");
|
|
|
|
// 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)
|
|
);
|
|
// Recalculate summary counts after filtering
|
|
audit.summary = {
|
|
critical: audit.findings.filter((f) => f?.severity === "critical").length,
|
|
warn: audit.findings.filter((f) => f?.severity === "warn").length,
|
|
info: audit.findings.filter((f) => f?.severity === "info").length,
|
|
};
|
|
}
|
|
if (deep.findings) {
|
|
deep.findings = activeFindings.filter((f) =>
|
|
(deep.findings || []).some((orig) => orig === f)
|
|
);
|
|
// Recalculate summary counts after filtering
|
|
deep.summary = {
|
|
critical: deep.findings.filter((f) => f?.severity === "critical").length,
|
|
warn: deep.findings.filter((f) => f?.severity === "warn").length,
|
|
info: deep.findings.filter((f) => f?.severity === "info").length,
|
|
};
|
|
}
|
|
|
|
// Render report with suppressed findings
|
|
const report = render({ audit, deep, label: args.label, suppressedFindings });
|
|
process.stdout.write(report + "\n");
|