mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-16 15:01:22 +03:00
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.
This commit is contained in:
Generated
-13
@@ -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==",
|
||||
|
||||
@@ -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 <path>` 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:
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 <path>` 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
|
||||
|
||||
@@ -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 <path>` 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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,9 +11,10 @@ show_help() {
|
||||
Usage: run_audit_and_format.sh [OPTIONS]
|
||||
|
||||
Options:
|
||||
--label <text> Custom label for the report
|
||||
--config <path> Path to config file (e.g., allowlist.json)
|
||||
--help Show this help message
|
||||
--label <text> Custom label for the report
|
||||
--config <path> 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
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"suppressions": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"suppressions": [
|
||||
invalid json here
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"suppressions": [
|
||||
{
|
||||
"checkId": "test.check",
|
||||
"skill": "test-skill"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ===`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user