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>
143 lines
5.2 KiB
JavaScript
143 lines
5.2 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { isObject, normalizeSkillName } from "./utils.mjs";
|
|
|
|
const DEFAULT_PRIMARY_PATH = path.join(os.homedir(), ".openclaw", "security-audit.json");
|
|
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.json";
|
|
|
|
const EMPTY_CONFIG = Object.freeze({
|
|
suppressions: [],
|
|
enabledFor: [],
|
|
source: "none",
|
|
});
|
|
|
|
/**
|
|
* @param {unknown} entry
|
|
* @param {number} index
|
|
* @param {string} source
|
|
* @returns {{ checkId: string, skill: string, reason: string, suppressedAt: string }}
|
|
*/
|
|
function normalizeRule(entry, index, source) {
|
|
if (!isObject(entry)) {
|
|
throw new Error(`Suppression entry at index ${index} in ${source} must be an object`);
|
|
}
|
|
|
|
const checkId = typeof entry.checkId === "string" ? entry.checkId.trim() : "";
|
|
const skill = typeof entry.skill === "string" ? entry.skill.trim() : "";
|
|
const reason = typeof entry.reason === "string" ? entry.reason.trim() : "";
|
|
const suppressedAt = typeof entry.suppressedAt === "string" ? entry.suppressedAt.trim() : "";
|
|
|
|
if (!checkId) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: checkId`);
|
|
if (!skill) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: skill`);
|
|
if (!reason) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: reason`);
|
|
if (!suppressedAt) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: suppressedAt`);
|
|
|
|
return { checkId, skill, reason, suppressedAt };
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} raw
|
|
* @param {string} source
|
|
* @returns {{ suppressions: Array, enabledFor: string[], source: string }}
|
|
*/
|
|
function parseConfig(raw, source) {
|
|
if (!isObject(raw)) {
|
|
throw new Error(`Config at ${source} must be a JSON object`);
|
|
}
|
|
|
|
if (!Array.isArray(raw.suppressions)) {
|
|
throw new Error(`Config at ${source} missing 'suppressions' array`);
|
|
}
|
|
|
|
const suppressions = [];
|
|
for (let i = 0; i < raw.suppressions.length; i++) {
|
|
suppressions.push(normalizeRule(raw.suppressions[i], i, source));
|
|
}
|
|
|
|
const enabledFor = Array.isArray(raw.enabledFor)
|
|
? raw.enabledFor
|
|
.filter((v) => typeof v === "string" && v.trim() !== "")
|
|
.map((v) => v.trim().toLowerCase())
|
|
: [];
|
|
|
|
return { suppressions, enabledFor, source };
|
|
}
|
|
|
|
/**
|
|
* @param {string} configPath
|
|
* @returns {Promise<{ suppressions: Array, enabledFor: string[], source: string } | null>}
|
|
*/
|
|
async function loadConfigFromPath(configPath) {
|
|
try {
|
|
const raw = await fs.readFile(configPath, "utf8");
|
|
return parseConfig(JSON.parse(raw), configPath);
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") return null;
|
|
if (err.code === "EACCES") throw new Error(`Permission denied reading config: ${configPath}`, { cause: err });
|
|
if (err instanceof SyntaxError) throw new Error(`Malformed JSON in ${configPath}: ${err.message}`, { cause: err });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load advisory suppression config using the same 4-tier path resolution
|
|
* as the audit watchdog config loader.
|
|
*
|
|
* The config file must include "advisory" in its enabledFor sentinel
|
|
* array for advisory suppression to activate. No CLI flag needed -- the
|
|
* sentinel in the config file IS the gate.
|
|
*
|
|
* @param {string} [configPath] - Optional explicit config file path
|
|
* @returns {Promise<{ suppressions: Array, enabledFor: string[], source: string }>}
|
|
*/
|
|
export async function loadAdvisorySuppression(configPath) {
|
|
// Priority 1: Explicit path
|
|
if (configPath) {
|
|
const config = await loadConfigFromPath(configPath);
|
|
if (!config) throw new Error(`Advisory suppression config not found: ${configPath}`);
|
|
if (!config.enabledFor.includes("advisory")) return { ...EMPTY_CONFIG };
|
|
return config;
|
|
}
|
|
|
|
// Priority 2: Environment variable
|
|
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
|
|
if (typeof envPath === "string" && envPath.trim()) {
|
|
const config = await loadConfigFromPath(envPath.trim());
|
|
if (config && config.enabledFor.includes("advisory")) return config;
|
|
return { ...EMPTY_CONFIG };
|
|
}
|
|
|
|
// Priority 3: Primary default path
|
|
const primary = await loadConfigFromPath(DEFAULT_PRIMARY_PATH);
|
|
if (primary && primary.enabledFor.includes("advisory")) return primary;
|
|
|
|
// Priority 4: Fallback path
|
|
const fallback = await loadConfigFromPath(DEFAULT_FALLBACK_PATH);
|
|
if (fallback && fallback.enabledFor.includes("advisory")) return fallback;
|
|
|
|
return { ...EMPTY_CONFIG };
|
|
}
|
|
|
|
/**
|
|
* Check if an advisory match should be suppressed.
|
|
*
|
|
* Matching requires BOTH:
|
|
* - advisory.id === rule.checkId (exact)
|
|
* - normalizeSkillName(skill.name) === normalizeSkillName(rule.skill) (case-insensitive)
|
|
*
|
|
* @param {{ advisory: { id?: string }, skill: { name: string } }} match
|
|
* @param {Array<{ checkId: string, skill: string }>} suppressions
|
|
* @returns {boolean}
|
|
*/
|
|
export function isAdvisorySuppressed(match, suppressions) {
|
|
if (!Array.isArray(suppressions) || suppressions.length === 0) return false;
|
|
|
|
const advisoryId = match.advisory.id ?? "";
|
|
const skillName = normalizeSkillName(match.skill.name);
|
|
|
|
return suppressions.some(
|
|
(rule) => rule.checkId === advisoryId && normalizeSkillName(rule.skill) === skillName,
|
|
);
|
|
}
|