From ac9759e0d74dddda6e609abb548ca4ef86e39d49 Mon Sep 17 00:00:00 2001 From: David Abutbul Date: Mon, 16 Feb 2026 17:35:15 +0200 Subject: [PATCH] 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. --- package-lock.json | 13 - skills/clawsec-suite/SKILL.md | 89 +++++ .../clawsec-advisory-guardian/handler.ts | 39 +- .../lib/suppression.mjs | 142 +++++++ .../scripts/setup_advisory_cron.mjs | 1 + .../scripts/setup_advisory_hook.mjs | 1 + .../test/advisory_suppression.test.mjs | 368 ++++++++++++++++++ skills/openclaw-audit-watchdog/README.md | 73 +++- skills/openclaw-audit-watchdog/SKILL.md | 73 ++++ .../scripts/load_suppression_config.mjs | 97 +++-- .../scripts/render_report.mjs | 9 +- .../scripts/run_audit_and_format.sh | 13 +- .../openclaw-audit-watchdog/scripts/runner.sh | 20 + .../test/empty-suppressions.json | 3 + .../test/invalid-json.json | 5 + .../test/malformed-config.json | 8 + .../test/render_report_suppression.test.mjs | 72 +++- .../test/suppression_config.test.mjs | 352 +++++++++++++---- 18 files changed, 1233 insertions(+), 145 deletions(-) create mode 100644 skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/suppression.mjs create mode 100644 skills/clawsec-suite/test/advisory_suppression.test.mjs create mode 100644 skills/openclaw-audit-watchdog/test/empty-suppressions.json create mode 100644 skills/openclaw-audit-watchdog/test/invalid-json.json create mode 100644 skills/openclaw-audit-watchdog/test/malformed-config.json diff --git a/package-lock.json b/package-lock.json index a68495d..4de5db2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1019,14 +1019,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/react": { - "version": "19.2.11", - "integrity": "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==", - "peer": true, - "dependencies": { - "csstype": "^3.2.2" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" @@ -1731,11 +1723,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.3", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "peer": true - }, "node_modules/data-view-buffer": { "version": "1.0.2", "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", diff --git a/skills/clawsec-suite/SKILL.md b/skills/clawsec-suite/SKILL.md index c176c22..8a57392 100644 --- a/skills/clawsec-suite/SKILL.md +++ b/skills/clawsec-suite/SKILL.md @@ -257,6 +257,95 @@ If an advisory indicates a malicious or removal-recommended skill and that skill The suite hook and heartbeat guidance are intentionally non-destructive by default. +## Advisory Suppression / Allowlist + +The advisory guardian pipeline supports opt-in suppression for advisories that have been reviewed and accepted by your security team. This is useful for first-party tooling or advisories that do not apply to your deployment. + +### Activation + +Advisory suppression requires a single gate: the configuration file must contain `"enabledFor"` with `"advisory"` in the array. No CLI flag is needed -- the sentinel in the config file IS the opt-in gate. + +If the `enabledFor` array is missing, empty, or does not include `"advisory"`, all advisories are reported normally. + +### Config File Resolution (4-tier) + +The advisory guardian resolves the suppression config using the same priority order as the audit pipeline: + +1. Explicit `--config ` argument +2. `OPENCLAW_AUDIT_CONFIG` environment variable +3. `~/.openclaw/security-audit.json` +4. `.clawsec/allowlist.json` + +### Config Format + +```json +{ + "enabledFor": ["advisory"], + "suppressions": [ + { + "checkId": "CVE-2026-25593", + "skill": "clawsec-suite", + "reason": "First-party security tooling — reviewed by security team", + "suppressedAt": "2026-02-15" + }, + { + "checkId": "CLAW-2026-0001", + "skill": "example-skill", + "reason": "Advisory does not apply to our deployment configuration", + "suppressedAt": "2026-02-16" + } + ] +} +``` + +### Sentinel Semantics + +- `"enabledFor": ["advisory"]` -- only advisory suppression active +- `"enabledFor": ["audit"]` -- only audit suppression active (no effect on advisory pipeline) +- `"enabledFor": ["audit", "advisory"]` -- both pipelines honor suppressions +- Missing or empty `enabledFor` -- no suppression active (safe default) + +### Matching Rules + +- **checkId:** exact match against the advisory ID (e.g., `CVE-2026-25593` or `CLAW-2026-0001`) +- **skill:** case-insensitive match against the affected skill name from the advisory +- Both fields must match for an advisory to be suppressed + +### Required Fields per Suppression Entry + +| Field | Description | Example | +|-------|-------------|---------| +| `checkId` | Advisory ID to suppress | `CVE-2026-25593` | +| `skill` | Affected skill name | `clawsec-suite` | +| `reason` | Justification for audit trail (required) | `First-party tooling, reviewed by security team` | +| `suppressedAt` | ISO 8601 date (YYYY-MM-DD) | `2026-02-15` | + +### Shared Config with Audit Pipeline + +The advisory and audit pipelines share the same config file. Use the `enabledFor` array to control which pipelines honor the suppression list: + +```json +{ + "enabledFor": ["audit", "advisory"], + "suppressions": [ + { + "checkId": "skills.code_safety", + "skill": "clawsec-suite", + "reason": "First-party tooling — audit finding accepted", + "suppressedAt": "2026-02-15" + }, + { + "checkId": "CVE-2026-25593", + "skill": "clawsec-suite", + "reason": "First-party tooling — advisory reviewed", + "suppressedAt": "2026-02-15" + } + ] +} +``` + +Audit entries (with check identifiers like `skills.code_safety`) are only matched by the audit pipeline. Advisory entries (with advisory IDs like `CVE-2026-25593` or `CLAW-2026-0001`) are only matched by the advisory pipeline. Each pipeline filters for its own relevant entries. + ## Optional Skill Installation Discover currently available installable skills dynamically, then install the ones you want: diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts index 73c5632..e0cb2f4 100644 --- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts @@ -6,6 +6,7 @@ import { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.m import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts"; import { loadState, persistState } from "./lib/state.ts"; import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts"; +import { loadAdvisorySuppression, isAdvisorySuppressed } from "./lib/suppression.mjs"; const DEFAULT_FEED_URL = "https://clawsec.prompt.security/advisories/feed.json"; @@ -171,13 +172,33 @@ const handler = async (event: HookEvent): Promise => { state.known_advisories = uniqueStrings([...state.known_advisories, ...advisoryIds]); const installedSkills = await discoverInstalledSkills(installRoot); - const matches = findMatches(feed, installedSkills); + const allMatches = findMatches(feed, installedSkills); - if (matches.length === 0) { + if (allMatches.length === 0) { await persistState(stateFile, state); return; } + // Load advisory suppression config (sentinel-gated: requires enabledFor: ["advisory"]) + let suppressionConfig; + try { + suppressionConfig = await loadAdvisorySuppression(); + } catch (err) { + console.warn(`[clawsec-advisory-guardian] failed to load suppression config: ${String(err)}`); + suppressionConfig = { suppressions: [], enabledFor: [], source: "none" }; + } + + // Partition matches into active and suppressed + const matches: AdvisoryMatch[] = []; + const suppressedMatches: AdvisoryMatch[] = []; + for (const match of allMatches) { + if (isAdvisorySuppressed(match, suppressionConfig.suppressions)) { + suppressedMatches.push(match); + } else { + matches.push(match); + } + } + const unseenMatches: AdvisoryMatch[] = []; for (const match of matches) { const key = matchKey(match); @@ -188,10 +209,24 @@ const handler = async (event: HookEvent): Promise => { state.notified_matches[key] = nowIso; } + // Track suppressed matches in state (so they aren't re-evaluated) but don't alert + for (const match of suppressedMatches) { + const key = matchKey(match); + if (!state.notified_matches[key]) { + state.notified_matches[key] = nowIso; + } + } + if (unseenMatches.length > 0 && Array.isArray(event.messages)) { event.messages.push(buildAlertMessage(unseenMatches, installRoot)); } + if (suppressedMatches.length > 0 && Array.isArray(event.messages)) { + event.messages.push( + `[clawsec-advisory-guardian] ${suppressedMatches.length} advisory match(es) suppressed by allowlist config.`, + ); + } + await persistState(stateFile, state); }; diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/suppression.mjs b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/suppression.mjs new file mode 100644 index 0000000..005e132 --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/suppression.mjs @@ -0,0 +1,142 @@ +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, + ); +} diff --git a/skills/clawsec-suite/scripts/setup_advisory_cron.mjs b/skills/clawsec-suite/scripts/setup_advisory_cron.mjs index 8d1fba4..b15032f 100644 --- a/skills/clawsec-suite/scripts/setup_advisory_cron.mjs +++ b/skills/clawsec-suite/scripts/setup_advisory_cron.mjs @@ -33,6 +33,7 @@ function requireOpenClawCli() { throw new Error( "openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " + `Original error: ${String(error)}`, + { cause: error }, ); } } diff --git a/skills/clawsec-suite/scripts/setup_advisory_hook.mjs b/skills/clawsec-suite/scripts/setup_advisory_hook.mjs index 497f5cc..8a239fa 100644 --- a/skills/clawsec-suite/scripts/setup_advisory_hook.mjs +++ b/skills/clawsec-suite/scripts/setup_advisory_hook.mjs @@ -37,6 +37,7 @@ function requireOpenClawCli() { throw new Error( "openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " + `Original error: ${String(error)}`, + { cause: error }, ); } } diff --git a/skills/clawsec-suite/test/advisory_suppression.test.mjs b/skills/clawsec-suite/test/advisory_suppression.test.mjs new file mode 100644 index 0000000..71f9998 --- /dev/null +++ b/skills/clawsec-suite/test/advisory_suppression.test.mjs @@ -0,0 +1,368 @@ +#!/usr/bin/env node + +/** + * Advisory suppression tests for clawsec-suite. + * + * Tests cover: + * - isAdvisorySuppressed matching logic (exact checkId + normalized skill name) + * - Partial matches do not suppress (checkId only, skill only) + * - Empty suppressions never suppress + * - loadAdvisorySuppression sentinel gating (enabledFor: ["advisory"]) + * - Missing sentinel returns empty config + * - Wrong sentinel (only "audit") returns empty config + * + * Run: node skills/clawsec-suite/test/advisory_suppression.test.mjs + */ + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib"); + +const { isAdvisorySuppressed, loadAdvisorySuppression } = await import( + `${LIB_PATH}/suppression.mjs` +); + +let tempDir; +let passCount = 0; +let failCount = 0; + +function pass(name) { + passCount++; + console.log(`\u2713 ${name}`); +} + +function fail(name, error) { + failCount++; + console.error(`\u2717 ${name}`); + console.error(` ${String(error)}`); +} + +async function setupTestDir() { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "advisory-suppression-test-")); +} + +async function cleanupTestDir() { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +function makeMatch(advisoryId, skillName, version = "1.0.0") { + return { + advisory: { id: advisoryId, severity: "high", title: `Advisory ${advisoryId}` }, + skill: { name: skillName, dirName: skillName, version }, + matchedAffected: [`${skillName}@<=${version}`], + }; +} + +function makeRules(entries) { + return entries.map(([checkId, skill, reason]) => ({ + checkId, + skill, + reason: reason || "Test suppression", + suppressedAt: "2026-02-15", + })); +} + +// --------------------------------------------------------------------------- +// isAdvisorySuppressed tests +// --------------------------------------------------------------------------- + +async function testExactMatch() { + const testName = "isAdvisorySuppressed: exact match suppresses"; + try { + const match = makeMatch("CVE-2026-25593", "clawsec-suite"); + const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]); + if (isAdvisorySuppressed(match, rules) === true) { + pass(testName); + } else { + fail(testName, "Expected suppression but got false"); + } + } catch (error) { + fail(testName, error); + } +} + +async function testCaseInsensitiveSkillMatch() { + const testName = "isAdvisorySuppressed: case-insensitive skill name match"; + try { + const match = makeMatch("CVE-2026-25593", "ClawSec-Suite"); + const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]); + if (isAdvisorySuppressed(match, rules) === true) { + pass(testName); + } else { + fail(testName, "Expected case-insensitive match to suppress"); + } + } catch (error) { + fail(testName, error); + } +} + +async function testCheckIdMismatch() { + const testName = "isAdvisorySuppressed: checkId mismatch does not suppress"; + try { + const match = makeMatch("CVE-2026-99999", "clawsec-suite"); + const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]); + if (isAdvisorySuppressed(match, rules) === false) { + pass(testName); + } else { + fail(testName, "Expected no suppression for mismatched checkId"); + } + } catch (error) { + fail(testName, error); + } +} + +async function testSkillMismatch() { + const testName = "isAdvisorySuppressed: skill mismatch does not suppress"; + try { + const match = makeMatch("CVE-2026-25593", "other-skill"); + const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]); + if (isAdvisorySuppressed(match, rules) === false) { + pass(testName); + } else { + fail(testName, "Expected no suppression for mismatched skill"); + } + } catch (error) { + fail(testName, error); + } +} + +async function testEmptySuppressions() { + const testName = "isAdvisorySuppressed: empty suppressions never suppress"; + try { + const match = makeMatch("CVE-2026-25593", "clawsec-suite"); + if (isAdvisorySuppressed(match, []) === false) { + pass(testName); + } else { + fail(testName, "Expected no suppression with empty rules"); + } + } catch (error) { + fail(testName, error); + } +} + +async function testMultipleRules() { + const testName = "isAdvisorySuppressed: multiple rules match correct one"; + try { + const match = makeMatch("CLAW-2026-0001", "openclaw-audit-watchdog"); + const rules = makeRules([ + ["CVE-2026-25593", "clawsec-suite"], + ["CLAW-2026-0001", "openclaw-audit-watchdog"], + ]); + if (isAdvisorySuppressed(match, rules) === true) { + pass(testName); + } else { + fail(testName, "Expected match against second rule"); + } + } catch (error) { + fail(testName, error); + } +} + +async function testMissingAdvisoryId() { + const testName = "isAdvisorySuppressed: missing advisory.id does not suppress"; + try { + const match = { + advisory: { severity: "high", title: "No ID advisory" }, + skill: { name: "clawsec-suite", dirName: "clawsec-suite", version: "1.0.0" }, + matchedAffected: [], + }; + const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]); + if (isAdvisorySuppressed(match, rules) === false) { + pass(testName); + } else { + fail(testName, "Expected no suppression when advisory has no id"); + } + } catch (error) { + fail(testName, error); + } +} + +// --------------------------------------------------------------------------- +// loadAdvisorySuppression tests +// --------------------------------------------------------------------------- + +async function testLoadWithAdvisorySentinel() { + const testName = "loadAdvisorySuppression: loads config with advisory sentinel"; + try { + const configFile = path.join(tempDir, "advisory-config.json"); + await fs.writeFile(configFile, JSON.stringify({ + enabledFor: ["advisory"], + suppressions: [{ + checkId: "CVE-2026-25593", + skill: "clawsec-suite", + reason: "First-party tooling", + suppressedAt: "2026-02-15", + }], + })); + + const config = await loadAdvisorySuppression(configFile); + if (config.suppressions.length === 1 && config.source === configFile) { + pass(testName); + } else { + fail(testName, `Expected 1 suppression from ${configFile}, got: ${JSON.stringify(config)}`); + } + } catch (error) { + fail(testName, error); + } +} + +async function testLoadWithMissingSentinel() { + const testName = "loadAdvisorySuppression: missing sentinel returns empty config"; + try { + const configFile = path.join(tempDir, "no-sentinel.json"); + await fs.writeFile(configFile, JSON.stringify({ + suppressions: [{ + checkId: "CVE-2026-25593", + skill: "clawsec-suite", + reason: "First-party tooling", + suppressedAt: "2026-02-15", + }], + })); + + const config = await loadAdvisorySuppression(configFile); + if (config.suppressions.length === 0) { + pass(testName); + } else { + fail(testName, `Expected empty suppressions without sentinel, got: ${JSON.stringify(config)}`); + } + } catch (error) { + fail(testName, error); + } +} + +async function testLoadWithAuditOnlySentinel() { + const testName = "loadAdvisorySuppression: audit-only sentinel returns empty for advisory"; + try { + const configFile = path.join(tempDir, "audit-only.json"); + await fs.writeFile(configFile, JSON.stringify({ + enabledFor: ["audit"], + suppressions: [{ + checkId: "CVE-2026-25593", + skill: "clawsec-suite", + reason: "First-party tooling", + suppressedAt: "2026-02-15", + }], + })); + + const config = await loadAdvisorySuppression(configFile); + if (config.suppressions.length === 0) { + pass(testName); + } else { + fail(testName, `Expected empty for audit-only sentinel, got: ${JSON.stringify(config)}`); + } + } catch (error) { + fail(testName, error); + } +} + +async function testLoadWithBothSentinels() { + const testName = "loadAdvisorySuppression: both audit+advisory sentinels activates advisory"; + try { + const configFile = path.join(tempDir, "both-sentinel.json"); + await fs.writeFile(configFile, JSON.stringify({ + enabledFor: ["audit", "advisory"], + suppressions: [{ + checkId: "CVE-2026-25593", + skill: "clawsec-suite", + reason: "First-party tooling", + suppressedAt: "2026-02-15", + }], + })); + + const config = await loadAdvisorySuppression(configFile); + if (config.suppressions.length === 1) { + pass(testName); + } else { + fail(testName, `Expected 1 suppression with both sentinels, got: ${JSON.stringify(config)}`); + } + } catch (error) { + fail(testName, error); + } +} + +async function testLoadNonexistentExplicitPath() { + const testName = "loadAdvisorySuppression: explicit nonexistent path throws"; + try { + await loadAdvisorySuppression(path.join(tempDir, "does-not-exist.json")); + fail(testName, "Expected error for nonexistent explicit path"); + } catch (error) { + if (String(error).includes("not found")) { + pass(testName); + } else { + fail(testName, `Unexpected error: ${error}`); + } + } +} + +async function testLoadNoConfigReturnsEmpty() { + const testName = "loadAdvisorySuppression: no config available returns empty"; + try { + // Clear env var to ensure no ambient config + const savedEnv = process.env.OPENCLAW_AUDIT_CONFIG; + delete process.env.OPENCLAW_AUDIT_CONFIG; + + try { + // Call without explicit path and with no env var — falls through to default paths + // which likely don't exist in test environment + const config = await loadAdvisorySuppression(); + if (config.suppressions.length === 0 && config.source === "none") { + pass(testName); + } else { + fail(testName, `Expected empty config, got: ${JSON.stringify(config)}`); + } + } finally { + if (savedEnv !== undefined) process.env.OPENCLAW_AUDIT_CONFIG = savedEnv; + else delete process.env.OPENCLAW_AUDIT_CONFIG; + } + } catch (error) { + fail(testName, error); + } +} + +// --------------------------------------------------------------------------- +// Main test runner +// --------------------------------------------------------------------------- +async function runAllTests() { + console.log("=== Advisory Suppression Tests ===\n"); + + await setupTestDir(); + + try { + // isAdvisorySuppressed tests + await testExactMatch(); + await testCaseInsensitiveSkillMatch(); + await testCheckIdMismatch(); + await testSkillMismatch(); + await testEmptySuppressions(); + await testMultipleRules(); + await testMissingAdvisoryId(); + + // loadAdvisorySuppression tests + await testLoadWithAdvisorySentinel(); + await testLoadWithMissingSentinel(); + await testLoadWithAuditOnlySentinel(); + await testLoadWithBothSentinels(); + await testLoadNonexistentExplicitPath(); + await testLoadNoConfigReturnsEmpty(); + } finally { + await cleanupTestDir(); + } + + console.log(""); + console.log(`=== Results: ${passCount} passed, ${failCount} failed ===`); + + if (failCount > 0) { + process.exit(1); + } +} + +runAllTests().catch((err) => { + console.error("Test runner failed:", err); + process.exit(1); +}); diff --git a/skills/openclaw-audit-watchdog/README.md b/skills/openclaw-audit-watchdog/README.md index dff6c88..ca67827 100644 --- a/skills/openclaw-audit-watchdog/README.md +++ b/skills/openclaw-audit-watchdog/README.md @@ -39,23 +39,35 @@ export PROMPTSEC_HOST_LABEL="prod-agent-1" | `PROMPTSEC_GIT_PULL` | Pull latest before audit (0/1) | `0` | | `OPENCLAW_AUDIT_CONFIG` | Path to suppression config file | Auto-detected | -## Suppression Configuration +## Suppression / Allowlist -Manage false-positive findings with the built-in suppression mechanism. Suppressed findings remain visible in reports but don't count toward critical/warning totals. +Manage false-positive findings with the built-in suppression mechanism. Suppressed findings remain visible in reports but are demoted to informational status and do not count toward critical/warning totals. -### Config File Location +Suppression is **opt-in with defense in depth**: the audit pipeline requires BOTH a CLI flag AND a config-file sentinel before any finding is suppressed. This prevents accidental or unauthorized suppression. -The audit scanner checks these locations (in priority order): +### Activation (Two Gates) -1. `--config` flag argument +Both of the following must be true for audit suppressions to take effect: + +1. **CLI flag:** Pass `--enable-suppressions` when invoking the runner. +2. **Config sentinel:** The configuration file must contain `"enabledFor": ["audit"]` (or a list that includes `"audit"`). + +If either gate is missing, the suppression list is ignored entirely and all findings are reported normally. + +### Config File Resolution + +The audit scanner resolves the suppression config file using this 4-tier priority: + +1. `--config ` CLI argument (highest priority) 2. `OPENCLAW_AUDIT_CONFIG` environment variable -3. `~/.openclaw/security-audit.json` (primary) +3. `~/.openclaw/security-audit.json` 4. `.clawsec/allowlist.json` (fallback) ### Example Configuration ```json { + "enabledFor": ["audit"], "suppressions": [ { "checkId": "skills.code_safety", @@ -73,30 +85,51 @@ The audit scanner checks these locations (in priority order): } ``` -### Required Fields +The `enabledFor` array controls which pipelines honor the suppression list: -- **checkId**: Security check identifier (e.g., `skills.code_safety`) -- **skill**: Exact skill name to suppress -- **reason**: Justification for audit trail (required) -- **suppressedAt**: ISO 8601 date (YYYY-MM-DD) +| Value | Effect | +|-------|--------| +| `["audit"]` | Only audit suppression active (still requires `--enable-suppressions` flag) | +| `["advisory"]` | Only advisory suppression active (used by clawsec-suite) | +| `["audit", "advisory"]` | Both pipelines honor suppressions | +| Missing or `[]` | No suppression in any pipeline (safe default) | + +### Required Fields per Suppression Entry + +| Field | Description | Example | +|-------|-------------|---------| +| `checkId` | Audit check identifier to suppress | `skills.code_safety` | +| `skill` | Skill name the suppression applies to | `clawsec-suite` | +| `reason` | Justification for audit trail (required) | `First-party tooling, reviewed by security team` | +| `suppressedAt` | ISO 8601 date (YYYY-MM-DD) | `2026-02-15` | + +**Matching:** Suppression requires an exact `checkId` match and a case-insensitive `skill` name match. Both must match for a finding to be suppressed. ### Usage ```bash -# Use default config location -./scripts/runner.sh +# Enable suppressions with default config location +./scripts/runner.sh --enable-suppressions -# Specify custom config -./scripts/runner.sh --config /path/to/config.json +# Enable suppressions with explicit config path +./scripts/runner.sh --enable-suppressions --config /path/to/config.json -# Or set via environment +# Enable suppressions with config via environment variable export OPENCLAW_AUDIT_CONFIG=~/.openclaw/custom-audit.json +./scripts/runner.sh --enable-suppressions +``` + +Without `--enable-suppressions`, the config file is not consulted for suppressions: + +```bash +# Suppressions NOT active (flag missing) ./scripts/runner.sh +./scripts/runner.sh --config /path/to/config.json ``` ### Report Output -Suppressed findings appear in a separate section: +Suppressed findings appear in a separate informational section: ``` CRITICAL (0): @@ -106,14 +139,12 @@ WARNINGS (1): [skills.network] some-skill: Unrestricted network access INFO - SUPPRESSED (2): - ℹ [skills.code_safety] clawsec-suite: dangerous-exec detected + [skills.code_safety] clawsec-suite: dangerous-exec detected Reason: First-party security tooling, reviewed 2026-02-13 - ℹ [skills.permissions] my-tool: Broad permission scope + [skills.permissions] my-tool: Broad permission scope Reason: Validated by security team, suppressedAt 2026-02-16 ``` -**Important**: Suppressions require BOTH `checkId` AND `skill` to match. This prevents over-suppression and maintains audit integrity. - See `examples/security-audit-config.example.json` for a complete template. ## Scripts diff --git a/skills/openclaw-audit-watchdog/SKILL.md b/skills/openclaw-audit-watchdog/SKILL.md index 4f6fe99..6c4ba70 100644 --- a/skills/openclaw-audit-watchdog/SKILL.md +++ b/skills/openclaw-audit-watchdog/SKILL.md @@ -184,6 +184,79 @@ export PROMPTSEC_DM_TO="@oncall" Each will send reports with clear host identification. +### Example 7: Suppressing Known Findings + +To suppress audit findings that have been reviewed and accepted, pass the `--enable-suppressions` flag and ensure the config file includes the `"enabledFor": ["audit"]` sentinel: + +```bash +# Create or edit the suppression config +cat > ~/.openclaw/security-audit.json <<'JSON' +{ + "enabledFor": ["audit"], + "suppressions": [ + { + "checkId": "skills.code_safety", + "skill": "clawsec-suite", + "reason": "First-party security tooling — reviewed by security team", + "suppressedAt": "2026-02-15" + } + ] +} +JSON + +# Run with suppressions enabled +/openclaw-audit-watchdog --enable-suppressions +``` + +Suppressed findings still appear in the report under an informational section but are excluded from critical/warning totals. + +## Suppression / Allowlist + +The audit pipeline supports an opt-in suppression mechanism for managing reviewed findings. Suppression uses defense-in-depth activation: two independent gates must both be satisfied. + +### Activation Requirements + +1. **CLI flag:** The `--enable-suppressions` flag must be passed at invocation. +2. **Config sentinel:** The configuration file must include `"enabledFor"` with `"audit"` in the array. + +If either gate is absent, all findings are reported normally and the suppression list is ignored. + +### Config File Resolution (4-tier) + +1. Explicit `--config ` argument +2. `OPENCLAW_AUDIT_CONFIG` environment variable +3. `~/.openclaw/security-audit.json` +4. `.clawsec/allowlist.json` + +### Config Format + +```json +{ + "enabledFor": ["audit"], + "suppressions": [ + { + "checkId": "skills.code_safety", + "skill": "clawsec-suite", + "reason": "First-party security tooling — reviewed by security team", + "suppressedAt": "2026-02-15" + } + ] +} +``` + +### Sentinel Semantics + +- `"enabledFor": ["audit"]` -- audit suppression active (requires `--enable-suppressions` flag too) +- `"enabledFor": ["advisory"]` -- only advisory pipeline suppression (no effect on audit) +- `"enabledFor": ["audit", "advisory"]` -- both pipelines honor suppressions +- Missing or empty `enabledFor` -- no suppression active (safe default) + +### Matching Rules + +- **checkId:** exact match against the audit finding's check identifier (e.g., `skills.code_safety`) +- **skill:** case-insensitive match against the skill name from the finding +- Both fields must match for a finding to be suppressed + ## Installation flow (interactive) Provisioning (MDM-friendly): prefer environment variables (no prompts). diff --git a/skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs b/skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs index ab83f08..70a8aa1 100755 --- a/skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs +++ b/skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs @@ -84,12 +84,18 @@ function normalizeSuppressionConfig(payload, source) { const normalized = validateSuppression(rawSuppressions[i], i); suppressions.push(normalized); } catch (err) { - throw new Error(`Invalid suppression at index ${i} in ${source}: ${err.message}`); + throw new Error(`Invalid suppression at index ${i} in ${source}: ${err.message}`, { cause: err }); } } + // Extract enabledFor sentinel (array of pipeline names this config activates for) + const enabledFor = Array.isArray(payload.enabledFor) + ? payload.enabledFor.filter((v) => typeof v === "string" && v.trim() !== "").map((v) => v.trim().toLowerCase()) + : []; + return { suppressions, + enabledFor, source, }; } @@ -105,29 +111,23 @@ async function loadConfigFromPath(configPath) { return null; } if (err.code === "EACCES") { - throw new Error(`Permission denied reading config file: ${configPath}`); + throw new Error(`Permission denied reading config file: ${configPath}`, { cause: err }); } if (err instanceof SyntaxError) { - throw new Error(`Malformed JSON in config file ${configPath}: ${err.message}`); + throw new Error(`Malformed JSON in config file ${configPath}: ${err.message}`, { cause: err }); } // Re-throw validation errors or other errors throw err; } } +const EMPTY_RESULT = Object.freeze({ suppressions: [], source: "none" }); + /** - * Load suppression configuration with multi-path fallback. - * - * Behavior: - * - Checks primary path: ~/.openclaw/security-audit.json (or OPENCLAW_AUDIT_CONFIG env var) - * - Falls back to: .clawsec/allowlist.json - * - Returns empty suppressions array if no config found - * - Throws on malformed JSON or validation errors - * - * @param {string} [customPath] - Optional custom config file path - * @returns {Promise<{suppressions: Array, source: string}>} + * Resolve config from the 4-tier priority chain. + * Returns the loaded config or null if no config found. */ -export async function loadSuppressionConfig(customPath = null) { +async function resolveConfig(customPath) { // Priority 1: Custom path provided as argument if (customPath) { const config = await loadConfigFromPath(customPath); @@ -148,35 +148,70 @@ export async function loadSuppressionConfig(customPath = null) { } // Priority 3: Primary default path - const primaryPath = DEFAULT_PRIMARY_PATH; - const primaryConfig = await loadConfigFromPath(primaryPath); - if (primaryConfig) { - return primaryConfig; - } + const primaryConfig = await loadConfigFromPath(DEFAULT_PRIMARY_PATH); + if (primaryConfig) return primaryConfig; // Priority 4: Fallback path - const fallbackPath = DEFAULT_FALLBACK_PATH; - const fallbackConfig = await loadConfigFromPath(fallbackPath); - if (fallbackConfig) { - return fallbackConfig; + const fallbackConfig = await loadConfigFromPath(DEFAULT_FALLBACK_PATH); + if (fallbackConfig) return fallbackConfig; + + return null; +} + +/** + * Load suppression configuration with multi-path fallback and opt-in gating. + * + * Suppression requires explicit opt-in to prevent ambient activation: + * 1. The `enabled` flag must be true (set via --enable-suppressions CLI flag) + * 2. The config file must contain an `enabledFor` array including "audit" + * + * Without both gates, returns empty suppressions. + * + * @param {string} [customPath] - Optional custom config file path + * @param {object} [options] + * @param {boolean} [options.enabled=false] - Whether suppression is explicitly enabled + * @param {string} [options.pipeline="audit"] - Pipeline to check in enabledFor sentinel + * @returns {Promise<{suppressions: Array, source: string}>} + */ +export async function loadSuppressionConfig(customPath = null, { enabled = false, pipeline = "audit" } = {}) { + // Gate 1: suppression must be explicitly opted-in via CLI flag + if (!enabled) { + return EMPTY_RESULT; } - // No config found - return empty suppressions (graceful fallback) - return { - suppressions: [], - source: "none", - }; + const config = await resolveConfig(customPath); + if (!config) { + return EMPTY_RESULT; + } + + // Gate 2: config must declare this pipeline in enabledFor sentinel + if (!Array.isArray(config.enabledFor) || !config.enabledFor.includes(pipeline)) { + return EMPTY_RESULT; + } + + process.stderr.write( + `WARNING: Suppression mechanism is enabled for "${pipeline}" pipeline via --enable-suppressions flag.\n` + ); + + return config; } // CLI usage when run directly if (import.meta.url === `file://${process.argv[1]}`) { - const customPath = process.argv[2]; + const args = process.argv.slice(2); + const enableFlag = args.includes("--enable-suppressions"); + const customPath = args.find((a) => !a.startsWith("--")) || null; + + if (!enableFlag) { + process.stdout.write("Suppression is disabled. Pass --enable-suppressions to activate.\n"); + process.exit(0); + } try { - const config = await loadSuppressionConfig(customPath || null); + const config = await loadSuppressionConfig(customPath, { enabled: true }); if (config.suppressions.length === 0) { - process.stdout.write("No suppression config found - graceful fallback to empty suppressions\n"); + process.stdout.write("No active suppressions (config missing, no enabledFor sentinel, or empty)\n"); process.stdout.write(JSON.stringify(config, null, 2) + "\n"); process.exit(0); } diff --git a/skills/openclaw-audit-watchdog/scripts/render_report.mjs b/skills/openclaw-audit-watchdog/scripts/render_report.mjs index 84f9c19..1d60659 100755 --- a/skills/openclaw-audit-watchdog/scripts/render_report.mjs +++ b/skills/openclaw-audit-watchdog/scripts/render_report.mjs @@ -3,7 +3,7 @@ * Render a human-readable security audit report from openclaw JSON. * * Usage: - * node render_report.mjs --audit audit.json --deep deep.json --label "host label" [--config config.json] + * node render_report.mjs --audit audit.json --deep deep.json --label "host label" [--enable-suppressions] [--config config.json] */ import fs from "node:fs"; @@ -194,6 +194,7 @@ function parseArgs(argv) { 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; } @@ -201,8 +202,10 @@ function parseArgs(argv) { // Main execution const args = parseArgs(process.argv.slice(2)); -// Load suppression config (async) -const suppressionConfig = await loadSuppressionConfig(args.config || null); +// Load suppression config (requires explicit opt-in) +const suppressionConfig = await loadSuppressionConfig(args.config || null, { + enabled: !!args.enableSuppressions, +}); const suppressions = suppressionConfig.suppressions || []; // Read audit results diff --git a/skills/openclaw-audit-watchdog/scripts/run_audit_and_format.sh b/skills/openclaw-audit-watchdog/scripts/run_audit_and_format.sh index 4bccdc6..fcf2cc2 100755 --- a/skills/openclaw-audit-watchdog/scripts/run_audit_and_format.sh +++ b/skills/openclaw-audit-watchdog/scripts/run_audit_and_format.sh @@ -11,9 +11,10 @@ show_help() { Usage: run_audit_and_format.sh [OPTIONS] Options: - --label Custom label for the report - --config Path to config file (e.g., allowlist.json) - --help Show this help message + --label Custom label for the report + --config Path to config file (e.g., allowlist.json) + --enable-suppressions Explicitly enable the suppression mechanism + --help Show this help message EOF exit 0 @@ -21,12 +22,15 @@ EOF LABEL="" CONFIG="" +ENABLE_SUPPRESSIONS=0 while [[ $# -gt 0 ]]; do case "$1" in --label) LABEL="${2:-}"; shift 2 ;; --config) CONFIG="${2:-}"; shift 2 ;; + --enable-suppressions) + ENABLE_SUPPRESSIONS=1; shift ;; --help) show_help ;; *) @@ -90,6 +94,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Build args for render_report RENDER_ARGS=(--audit "$AUDIT_JSON" --deep "$DEEP_JSON" --label "$LABEL") +if [[ "$ENABLE_SUPPRESSIONS" -eq 1 ]]; then + RENDER_ARGS+=(--enable-suppressions) +fi if [[ -n "$CONFIG" ]]; then RENDER_ARGS+=(--config "$CONFIG") fi diff --git a/skills/openclaw-audit-watchdog/scripts/runner.sh b/skills/openclaw-audit-watchdog/scripts/runner.sh index 9ee9a18..c24b374 100755 --- a/skills/openclaw-audit-watchdog/scripts/runner.sh +++ b/skills/openclaw-audit-watchdog/scripts/runner.sh @@ -10,10 +10,24 @@ set -euo pipefail COMPANY_EMAIL="${PROMPTSEC_EMAIL_TO:-target@example.com}" HOST_LABEL="${PROMPTSEC_HOST_LABEL:-}" DO_PULL="${PROMPTSEC_GIT_PULL:-0}" +ENABLE_SUPPRESSIONS=0 +AUDIT_CONFIG="" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# Parse CLI arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --enable-suppressions) + ENABLE_SUPPRESSIONS=1; shift ;; + --config) + AUDIT_CONFIG="${2:-}"; shift 2 ;; + *) + shift ;; + esac +done + if [[ "$DO_PULL" == "1" ]]; then if command -v git >/dev/null 2>&1 && [[ -d "$ROOT_DIR/.git" ]]; then git -C "$ROOT_DIR" pull --ff-only >/dev/null 2>&1 || true @@ -24,6 +38,12 @@ args=( ) if [[ -n "$HOST_LABEL" ]]; then args+=(--label "$HOST_LABEL") fi +if [[ "$ENABLE_SUPPRESSIONS" -eq 1 ]]; then + args+=(--enable-suppressions) +fi +if [[ -n "$AUDIT_CONFIG" ]]; then + args+=(--config "$AUDIT_CONFIG") +fi REPORT="$($SCRIPT_DIR/run_audit_and_format.sh "${args[@]}")" SUBJECT_HOST="${HOST_LABEL:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo unknown-host)}" diff --git a/skills/openclaw-audit-watchdog/test/empty-suppressions.json b/skills/openclaw-audit-watchdog/test/empty-suppressions.json new file mode 100644 index 0000000..3bf2fe7 --- /dev/null +++ b/skills/openclaw-audit-watchdog/test/empty-suppressions.json @@ -0,0 +1,3 @@ +{ + "suppressions": [] +} diff --git a/skills/openclaw-audit-watchdog/test/invalid-json.json b/skills/openclaw-audit-watchdog/test/invalid-json.json new file mode 100644 index 0000000..be15d5e --- /dev/null +++ b/skills/openclaw-audit-watchdog/test/invalid-json.json @@ -0,0 +1,5 @@ +{ + "suppressions": [ + invalid json here + ] +} diff --git a/skills/openclaw-audit-watchdog/test/malformed-config.json b/skills/openclaw-audit-watchdog/test/malformed-config.json new file mode 100644 index 0000000..6a5261b --- /dev/null +++ b/skills/openclaw-audit-watchdog/test/malformed-config.json @@ -0,0 +1,8 @@ +{ + "suppressions": [ + { + "checkId": "test.check", + "skill": "test-skill" + } + ] +} diff --git a/skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs b/skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs index eae90dd..40ace2a 100755 --- a/skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs +++ b/skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs @@ -71,9 +71,10 @@ function createAuditJson(findings) { }); } -function createConfigJson(suppressions) { +function createConfigJson(suppressions, enabledFor = ["audit"]) { return JSON.stringify({ - suppressions: suppressions, + enabledFor, + suppressions, }); } @@ -137,6 +138,7 @@ async function testSuppressedFindingsDisplayed() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -199,6 +201,7 @@ async function testActiveFindingsDisplayed() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -268,6 +271,7 @@ async function testSummaryExcludesSuppressed() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -361,6 +365,7 @@ async function testPartialMatchCheckIdOnly() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -417,6 +422,7 @@ async function testPartialMatchSkillOnly() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -491,6 +497,7 @@ async function testMultipleSuppressions() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -549,6 +556,7 @@ async function testSkillNameExtractionFromPath() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -604,6 +612,7 @@ async function testSkillNameExtractionFromTitle() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -650,6 +659,7 @@ async function testEmptySuppressions() { auditFile, "--deep", deepFile, + "--enable-suppressions", "--config", configFile, ]); @@ -669,6 +679,63 @@ async function testEmptySuppressions() { } } +// ----------------------------------------------------------------------------- +// Test: Config without --enable-suppressions flag does NOT suppress +// ----------------------------------------------------------------------------- +async function testConfigWithoutEnableFlagDoesNotSuppress() { + const testName = "render_report: config without --enable-suppressions flag does not suppress"; + try { + const auditFile = path.join(tempDir, "audit.json"); + const deepFile = path.join(tempDir, "deep.json"); + const configFile = path.join(tempDir, "config.json"); + + const findings = [ + { + severity: "critical", + checkId: "skills.code_safety", + skill: "clawsec-suite", + title: "dangerous-exec detected", + }, + ]; + + const suppressions = [ + { + checkId: "skills.code_safety", + skill: "clawsec-suite", + reason: "First-party security tooling", + suppressedAt: "2026-02-13", + }, + ]; + + await fs.writeFile(auditFile, createAuditJson(findings)); + await fs.writeFile(deepFile, createAuditJson([])); + await fs.writeFile(configFile, createConfigJson(suppressions)); + + // Pass --config but NOT --enable-suppressions + const result = await runRenderReport([ + "--audit", + auditFile, + "--deep", + deepFile, + "--config", + configFile, + ]); + + // Findings should NOT be suppressed without the explicit opt-in flag + if ( + result.stdout.includes("Summary: 1 critical") && + result.stdout.includes("Findings (critical/warn):") && + !result.stdout.includes("INFO-SUPPRESSED:") + ) { + pass(testName); + } else { + fail(testName, `Config alone should not suppress without --enable-suppressions: ${result.stdout}`); + } + } catch (error) { + fail(testName, error); + } +} + // ----------------------------------------------------------------------------- // Main test runner // ----------------------------------------------------------------------------- @@ -686,6 +753,7 @@ async function runAllTests() { await testSkillNameExtractionFromPath(); await testSkillNameExtractionFromTitle(); await testEmptySuppressions(); + await testConfigWithoutEnableFlagDoesNotSuppress(); } finally { await cleanupTestDir(); } diff --git a/skills/openclaw-audit-watchdog/test/suppression_config.test.mjs b/skills/openclaw-audit-watchdog/test/suppression_config.test.mjs index be3c711..130c1dd 100755 --- a/skills/openclaw-audit-watchdog/test/suppression_config.test.mjs +++ b/skills/openclaw-audit-watchdog/test/suppression_config.test.mjs @@ -10,6 +10,8 @@ * - Malformed JSON error handling * - File not found graceful fallback * - Multi-path priority (custom path > env var > primary > fallback) + * - Opt-in gate (enabled flag must be true) + * - enabledFor sentinel validation * * Run: node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs */ @@ -24,12 +26,12 @@ let failCount = 0; function pass(name) { passCount += 1; - console.log(`✓ ${name}`); + console.log(`\u2713 ${name}`); } function fail(name, error) { failCount += 1; - console.error(`✗ ${name}`); + console.error(`\u2717 ${name}`); console.error(` ${String(error)}`); } @@ -68,6 +70,22 @@ async function withEnv(key, value, fn) { } } +/** Suppress stderr output during a function call (avoids noisy warnings in test output). */ +async function silenceStderr(fn) { + const original = process.stderr.write; + process.stderr.write = () => true; + try { + return await fn(); + } finally { + process.stderr.write = original; + } +} + +/** Create a valid config JSON string with enabledFor sentinel. */ +function makeConfig(suppressions, enabledFor = ["audit"]) { + return JSON.stringify({ enabledFor, suppressions }); +} + // ----------------------------------------------------------------------------- // Test: valid config with all required fields // ----------------------------------------------------------------------------- @@ -76,25 +94,25 @@ async function testValidConfig() { let fixture = null; try { - const validConfig = JSON.stringify({ - suppressions: [ - { - checkId: "SCAN-001", - skill: "soul-guardian", - reason: "False positive - reviewed by security team", - suppressedAt: "2026-02-15", - }, - { - checkId: "SCAN-002", - skill: "clawtributor", - reason: "Accepted risk for legacy code", - suppressedAt: "2026-02-14", - }, - ], - }); + const validConfig = makeConfig([ + { + checkId: "SCAN-001", + skill: "soul-guardian", + reason: "False positive - reviewed by security team", + suppressedAt: "2026-02-15", + }, + { + checkId: "SCAN-002", + skill: "clawtributor", + reason: "Accepted risk for legacy code", + suppressedAt: "2026-02-14", + }, + ]); fixture = await withTempFile(validConfig); - const config = await loadSuppressionConfig(fixture.path); + const config = await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); if ( config.source === fixture.path && @@ -127,16 +145,14 @@ async function testMalformedDateWarning() { let fixture = null; try { - const configWithBadDate = JSON.stringify({ - suppressions: [ - { - checkId: "SCAN-003", - skill: "soul-guardian", - reason: "Test suppression", - suppressedAt: "02/15/2026", - }, - ], - }); + const configWithBadDate = makeConfig([ + { + checkId: "SCAN-003", + skill: "soul-guardian", + reason: "Test suppression", + suppressedAt: "02/15/2026", + }, + ]); fixture = await withTempFile(configWithBadDate); @@ -149,7 +165,7 @@ async function testMalformedDateWarning() { }; try { - const config = await loadSuppressionConfig(fixture.path); + const config = await loadSuppressionConfig(fixture.path, { enabled: true }); if ( config.suppressions.length === 1 && @@ -182,20 +198,20 @@ async function testMissingRequiredField() { let fixture = null; try { - const configMissingReason = JSON.stringify({ - suppressions: [ - { - checkId: "SCAN-004", - skill: "soul-guardian", - suppressedAt: "2026-02-15", - }, - ], - }); + const configMissingReason = makeConfig([ + { + checkId: "SCAN-004", + skill: "soul-guardian", + suppressedAt: "2026-02-15", + }, + ]); fixture = await withTempFile(configMissingReason); try { - await loadSuppressionConfig(fixture.path); + await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); fail(testName, "Expected error for missing required field"); } catch (err) { if (err.message.includes("missing required field: reason")) { @@ -226,7 +242,9 @@ async function testMalformedJSON() { fixture = await withTempFile(invalidJSON); try { - await loadSuppressionConfig(fixture.path); + await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); fail(testName, "Expected error for malformed JSON"); } catch (err) { if (err.message.includes("Malformed JSON")) { @@ -263,7 +281,9 @@ async function testFileNotFoundGracefulFallback() { // Expected - file should not exist } - const config = await loadSuppressionConfig(); + const config = await silenceStderr(() => + loadSuppressionConfig(null, { enabled: true }) + ); if (config.source === "none" && Array.isArray(config.suppressions) && config.suppressions.length === 0) { pass(testName); @@ -284,19 +304,19 @@ async function testCustomPathPriority() { let fixture = null; try { - const customConfig = JSON.stringify({ - suppressions: [ - { - checkId: "CUSTOM-001", - skill: "custom-skill", - reason: "Custom path config", - suppressedAt: "2026-02-15", - }, - ], - }); + const customConfig = makeConfig([ + { + checkId: "CUSTOM-001", + skill: "custom-skill", + reason: "Custom path config", + suppressedAt: "2026-02-15", + }, + ]); fixture = await withTempFile(customConfig); - const config = await loadSuppressionConfig(fixture.path); + const config = await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); if ( config.source === fixture.path && @@ -324,21 +344,21 @@ async function testEnvironmentVariableOverride() { let fixture = null; try { - const envConfig = JSON.stringify({ - suppressions: [ - { - checkId: "ENV-001", - skill: "env-skill", - reason: "Environment variable config", - suppressedAt: "2026-02-15", - }, - ], - }); + const envConfig = makeConfig([ + { + checkId: "ENV-001", + skill: "env-skill", + reason: "Environment variable config", + suppressedAt: "2026-02-15", + }, + ]); fixture = await withTempFile(envConfig); await withEnv("OPENCLAW_AUDIT_CONFIG", fixture.path, async () => { - const config = await loadSuppressionConfig(); + const config = await silenceStderr(() => + loadSuppressionConfig(null, { enabled: true }) + ); if ( config.source === fixture.path && @@ -368,13 +388,16 @@ async function testMissingSuppressions() { try { const configWithoutSuppressions = JSON.stringify({ + enabledFor: ["audit"], note: "This config is missing the suppressions array", }); fixture = await withTempFile(configWithoutSuppressions); try { - await loadSuppressionConfig(fixture.path); + await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); fail(testName, "Expected error for missing suppressions array"); } catch (err) { if (err.message.includes("missing 'suppressions' array")) { @@ -400,12 +423,12 @@ async function testEmptySuppressions() { let fixture = null; try { - const emptyConfig = JSON.stringify({ - suppressions: [], - }); + const emptyConfig = makeConfig([], ["audit"]); fixture = await withTempFile(emptyConfig); - const config = await loadSuppressionConfig(fixture.path); + const config = await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); if (config.source === fixture.path && config.suppressions.length === 0) { pass(testName); @@ -431,7 +454,9 @@ async function testCustomPathNotFoundFails() { const nonExistentPath = path.join(os.tmpdir(), "absolutely-does-not-exist-12345.json"); try { - await loadSuppressionConfig(nonExistentPath); + await silenceStderr(() => + loadSuppressionConfig(nonExistentPath, { enabled: true }) + ); fail(testName, "Expected error for custom path not found"); } catch (err) { if (err.message.includes("Custom config file not found")) { @@ -445,6 +470,188 @@ async function testCustomPathNotFoundFails() { } } +// ----------------------------------------------------------------------------- +// Test: disabled by default (enabled flag not set) +// ----------------------------------------------------------------------------- +async function testDisabledByDefault() { + const testName = "loadSuppressionConfig: returns empty when enabled flag is not set"; + let fixture = null; + + try { + const validConfig = makeConfig([ + { + checkId: "SCAN-001", + skill: "test-skill", + reason: "Should not be loaded", + suppressedAt: "2026-02-15", + }, + ]); + fixture = await withTempFile(validConfig); + + // Custom path provided but enabled=false (default) + const config1 = await loadSuppressionConfig(fixture.path); + if (config1.source !== "none" || config1.suppressions.length !== 0) { + fail(testName, "Custom path should be ignored when enabled is not set"); + return; + } + + // Env var set but enabled=false (default) + await withEnv("OPENCLAW_AUDIT_CONFIG", fixture.path, async () => { + const config2 = await loadSuppressionConfig(); + if (config2.source !== "none" || config2.suppressions.length !== 0) { + fail(testName, "Env var should be ignored when enabled is not set"); + return; + } + }); + + pass(testName); + } catch (error) { + fail(testName, error); + } finally { + if (fixture) await fixture.cleanup(); + } +} + +// ----------------------------------------------------------------------------- +// Test: enabled explicitly loads config +// ----------------------------------------------------------------------------- +async function testEnabledExplicitly() { + const testName = "loadSuppressionConfig: loads config when explicitly enabled with sentinel"; + let fixture = null; + + try { + const validConfig = makeConfig([ + { + checkId: "SCAN-001", + skill: "test-skill", + reason: "Should be loaded", + suppressedAt: "2026-02-15", + }, + ]); + fixture = await withTempFile(validConfig); + const config = await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); + + if (config.source === fixture.path && config.suppressions.length === 1) { + pass(testName); + } else { + fail(testName, `Expected config to be loaded: ${JSON.stringify(config)}`); + } + } catch (error) { + fail(testName, error); + } finally { + if (fixture) await fixture.cleanup(); + } +} + +// ----------------------------------------------------------------------------- +// Test: env var alone does not activate suppression +// ----------------------------------------------------------------------------- +async function testEnvVarAloneDoesNotActivate() { + const testName = "loadSuppressionConfig: OPENCLAW_AUDIT_CONFIG alone does not activate suppression"; + let fixture = null; + + try { + const validConfig = makeConfig([ + { + checkId: "ENV-ATTACK", + skill: "target-skill", + reason: "Attacker suppression", + suppressedAt: "2026-02-15", + }, + ]); + fixture = await withTempFile(validConfig); + + await withEnv("OPENCLAW_AUDIT_CONFIG", fixture.path, async () => { + // Without enabled: true, env var should be ignored + const config = await loadSuppressionConfig(null, { enabled: false }); + if (config.source === "none" && config.suppressions.length === 0) { + pass(testName); + } else { + fail(testName, `Env var should not activate suppression: ${JSON.stringify(config)}`); + } + }); + } catch (error) { + fail(testName, error); + } finally { + if (fixture) await fixture.cleanup(); + } +} + +// ----------------------------------------------------------------------------- +// Test: missing enabledFor sentinel returns empty +// ----------------------------------------------------------------------------- +async function testMissingSentinel() { + const testName = "loadSuppressionConfig: missing enabledFor sentinel returns empty"; + let fixture = null; + + try { + // Config has suppressions but NO enabledFor field + const configNoSentinel = JSON.stringify({ + suppressions: [ + { + checkId: "SCAN-001", + skill: "test-skill", + reason: "Should not activate", + suppressedAt: "2026-02-15", + }, + ], + }); + fixture = await withTempFile(configNoSentinel); + const config = await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); + + if (config.source === "none" && config.suppressions.length === 0) { + pass(testName); + } else { + fail(testName, `Missing sentinel should return empty: ${JSON.stringify(config)}`); + } + } catch (error) { + fail(testName, error); + } finally { + if (fixture) await fixture.cleanup(); + } +} + +// ----------------------------------------------------------------------------- +// Test: wrong enabledFor sentinel returns empty +// ----------------------------------------------------------------------------- +async function testWrongSentinel() { + const testName = "loadSuppressionConfig: wrong enabledFor sentinel returns empty for audit"; + let fixture = null; + + try { + // Config has enabledFor: ["advisory"] but not "audit" + const configWrongSentinel = makeConfig( + [ + { + checkId: "SCAN-001", + skill: "test-skill", + reason: "Should not activate for audit", + suppressedAt: "2026-02-15", + }, + ], + ["advisory"] + ); + fixture = await withTempFile(configWrongSentinel); + const config = await silenceStderr(() => + loadSuppressionConfig(fixture.path, { enabled: true }) + ); + + if (config.source === "none" && config.suppressions.length === 0) { + pass(testName); + } else { + fail(testName, `Wrong sentinel should return empty: ${JSON.stringify(config)}`); + } + } catch (error) { + fail(testName, error); + } finally { + if (fixture) await fixture.cleanup(); + } +} + // ----------------------------------------------------------------------------- // Main test runner // ----------------------------------------------------------------------------- @@ -461,6 +668,11 @@ async function runTests() { await testMissingSuppressions(); await testEmptySuppressions(); await testCustomPathNotFoundFails(); + await testDisabledByDefault(); + await testEnabledExplicitly(); + await testEnvVarAloneDoesNotActivate(); + await testMissingSentinel(); + await testWrongSentinel(); console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);