Files
clawsec/skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
T
davida-ps 63de5ce08d Security Audit Suppression Mechanism (fulfills https://github.com/prompt-security/clawsec/issues/25) (#40)
* auto-claude: subtask-1-1 - Create config loading utility with multi-path fallback

Created load_suppression_config.mjs with:
- Multi-path fallback: ~/.openclaw/security-audit.json -> .clawsec/allowlist.json
- Environment variable support (OPENCLAW_AUDIT_CONFIG)
- Custom path support via CLI argument
- Schema validation (checkId, skill, reason, suppressedAt required)
- Malformed JSON error handling
- Graceful fallback to empty suppressions when no config exists
- ISO 8601 date format validation with warnings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Create example config file template

- Added security-audit-config.example.json with two suppression examples
- Included examples for clawsec-suite and openclaw-audit-watchdog
- Created comprehensive README.md explaining configuration format
- All required fields documented (checkId, skill, reason, suppressedAt)
- ISO 8601 date format demonstrated
- JSON validated successfully

* auto-claude: subtask-1-3 - Add unit tests for config loading

Added comprehensive unit tests for suppression config loading:
- Valid config with all required fields
- Malformed date warning (non-blocking)
- Missing required field validation
- Malformed JSON error handling
- File not found graceful fallback
- Custom path priority
- Environment variable override
- Missing/empty suppressions array handling

All 10 tests passing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Add INFO-SUPPRESSED section to report output

- Added lineForSuppressedFinding() to format suppressed findings
- Added INFO-SUPPRESSED section showing suppressed findings with reason and date
- Suppressed findings are not counted in summary (already filtered)
- Follows existing code patterns for report sections

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Add --config flag to run_audit_and_format.sh

- Added --config flag to accept path to config file
- Added --help flag with usage documentation
- Config flag is passed to openclaw audit commands when provided
- Follows existing pattern for --label flag

* auto-claude: subtask-4-1 - Create integration tests for render_report with suppressions

Created comprehensive integration tests covering:
- Suppressed findings appear in INFO-SUPPRESSED section
- Active findings appear in CRITICAL/WARN section
- Summary counts exclude suppressed findings
- Backward compatibility (no config)
- Partial matches don't suppress (checkId or skill alone)
- Multiple suppressions work correctly
- Skill name extraction from path field
- Skill name extraction from title field
- Empty suppressions array behaves like no config

Bug fix in render_report.mjs:
- Summary counts now recalculated after filtering suppressed findings
- Previously summary showed original counts instead of filtered counts

All 10 tests passing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-2 - Manual E2E test with real openclaw audit

- Fixed run_audit_and_format.sh to pass --config flag to render_report.mjs
- Enhanced lineForFinding() to display skill names for better clarity
- Enhanced lineForSuppressedFinding() to display skill names consistently
- Created comprehensive E2E test documentation in E2E-TEST-RESULTS.md
- All E2E verification points passed:
  * Config loading from custom paths
  * Suppression matching by checkId + skill name
  * INFO-SUPPRESSED section display
  * Suppression reason and date display
  * Summary count accuracy (excludes suppressed findings)
  * Non-suppressed findings preservation
  * Skill name display in all findings
- All integration tests still passing (10/10)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-5-1 - Update README.md with suppression feature

* auto-claude: subtask-5-2 - Update SKILL.md with usage examples

* - Add backslash escaping before quote escaping in oneline() function
- Prevents incomplete string escaping vulnerability
- Resolves CodeQL alert: https://github.com/prompt-security/clawsec/security/code-scanning/16

* Fix regex in extractSkillName function and simplify error handling in suppression config tests

* Enhance suppression mechanism in OpenClaw Audit Watchdog

- Updated README.md to clarify suppression configuration and activation requirements.
- Improved SKILL.md with examples for suppressing known findings.
- Refactored load_suppression_config.mjs to implement opt-in gating for suppressions.
- Modified render_report.mjs to support suppression flag in report generation.
- Enhanced run_audit_and_format.sh and runner.sh scripts to accept --enable-suppressions flag.
- Added test cases for suppression configuration, including validation for enabledFor sentinel and opt-in behavior.
- Introduced new test files for empty and invalid suppression configurations.

* Fix type assertion for checksums file entries in Checksums component

* Update ESLint configuration and dependencies to pin @eslint/js to version 9.28.0

* Update CHANGELOG.md for advisory suppression module and OpenClaw Audit Watchdog enhancements

* Refactor finding comparison logic in render_report.mjs to simplify equality checks

* chore(clawsec-suite): bump version to 0.1.2

* chore(openclaw-audit-watchdog): bump version to 0.1.0

* Remove suppressed matches tracking from state to prevent re-evaluation alerts

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-16 18:55:06 +02:00

688 lines
20 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Suppression config loading tests for openclaw-audit-watchdog.
*
* Tests cover:
* - Valid config file loading and normalization
* - Required field validation
* - Date format validation with graceful fallback
* - 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
*/
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { loadSuppressionConfig } from "../scripts/load_suppression_config.mjs";
let passCount = 0;
let failCount = 0;
function pass(name) {
passCount += 1;
console.log(`\u2713 ${name}`);
}
function fail(name, error) {
failCount += 1;
console.error(`\u2717 ${name}`);
console.error(` ${String(error)}`);
}
async function withTempFile(content) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-"));
const tmpFile = path.join(tmpDir, "test-config.json");
await fs.writeFile(tmpFile, content, "utf8");
return {
path: tmpFile,
cleanup: async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
},
};
}
async function withEnv(key, value, fn) {
const oldValue = process.env[key];
try {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
return await fn();
} finally {
if (oldValue === undefined) {
delete process.env[key];
} else {
process.env[key] = oldValue;
}
}
}
/** 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
// -----------------------------------------------------------------------------
async function testValidConfig() {
const testName = "loadSuppressionConfig: loads valid config with all required fields";
let fixture = null;
try {
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 silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (
config.source === fixture.path &&
config.suppressions.length === 2 &&
config.suppressions[0].checkId === "SCAN-001" &&
config.suppressions[0].skill === "soul-guardian" &&
config.suppressions[0].reason === "False positive - reviewed by security team" &&
config.suppressions[0].suppressedAt === "2026-02-15" &&
config.suppressions[1].checkId === "SCAN-002" &&
config.suppressions[1].skill === "clawtributor"
) {
pass(testName);
} else {
fail(testName, `Unexpected config: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: malformed date warns but doesn't fail
// -----------------------------------------------------------------------------
async function testMalformedDateWarning() {
const testName = "loadSuppressionConfig: malformed date warns but doesn't fail";
let fixture = null;
try {
const configWithBadDate = makeConfig([
{
checkId: "SCAN-003",
skill: "soul-guardian",
reason: "Test suppression",
suppressedAt: "02/15/2026",
},
]);
fixture = await withTempFile(configWithBadDate);
// Capture stderr to check for warning
let stderrOutput = "";
const originalStderrWrite = process.stderr.write;
process.stderr.write = function (chunk) {
stderrOutput += chunk.toString();
return true;
};
try {
const config = await loadSuppressionConfig(fixture.path, { enabled: true });
if (
config.suppressions.length === 1 &&
config.suppressions[0].checkId === "SCAN-003" &&
config.suppressions[0].suppressedAt === "02/15/2026" &&
stderrOutput.includes("Warning") &&
stderrOutput.includes("malformed date")
) {
pass(testName);
} else {
fail(testName, `Expected warning but got: ${stderrOutput}`);
}
} finally {
process.stderr.write = originalStderrWrite;
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: missing required field fails
// -----------------------------------------------------------------------------
async function testMissingRequiredField() {
const testName = "loadSuppressionConfig: missing required field fails";
let fixture = null;
try {
const configMissingReason = makeConfig([
{
checkId: "SCAN-004",
skill: "soul-guardian",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(configMissingReason);
try {
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")) {
pass(testName);
} else {
fail(testName, `Wrong error message: ${err.message}`);
}
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: malformed JSON fails
// -----------------------------------------------------------------------------
async function testMalformedJSON() {
const testName = "loadSuppressionConfig: malformed JSON fails";
let fixture = null;
try {
const invalidJSON = "{ suppressions: [ { not valid json } ] }";
fixture = await withTempFile(invalidJSON);
try {
await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
fail(testName, "Expected error for malformed JSON");
} catch (err) {
if (err.message.includes("Malformed JSON")) {
pass(testName);
} else {
fail(testName, `Wrong error message: ${err.message}`);
}
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: file not found returns empty suppressions
// -----------------------------------------------------------------------------
async function testFileNotFoundGracefulFallback() {
const testName = "loadSuppressionConfig: file not found returns empty suppressions";
try {
await withEnv("OPENCLAW_AUDIT_CONFIG", undefined, async () => {
const nonExistentPath1 = path.join(os.homedir(), ".openclaw", "non-existent-12345.json");
// Ensure path does not exist
try {
await fs.access(nonExistentPath1);
fail(testName, "Test precondition failed: primary path should not exist");
return;
} catch {
// Expected - file should not exist
}
const config = await silenceStderr(() =>
loadSuppressionConfig(null, { enabled: true })
);
if (config.source === "none" && Array.isArray(config.suppressions) && config.suppressions.length === 0) {
pass(testName);
} else {
fail(testName, `Expected empty suppressions but got: ${JSON.stringify(config)}`);
}
});
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: custom path has highest priority
// -----------------------------------------------------------------------------
async function testCustomPathPriority() {
const testName = "loadSuppressionConfig: custom path has highest priority";
let fixture = null;
try {
const customConfig = makeConfig([
{
checkId: "CUSTOM-001",
skill: "custom-skill",
reason: "Custom path config",
suppressedAt: "2026-02-15",
},
]);
fixture = await withTempFile(customConfig);
const config = await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (
config.source === fixture.path &&
config.suppressions.length === 1 &&
config.suppressions[0].checkId === "CUSTOM-001"
) {
pass(testName);
} else {
fail(testName, `Unexpected config: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: environment variable override
// -----------------------------------------------------------------------------
async function testEnvironmentVariableOverride() {
const testName = "loadSuppressionConfig: environment variable overrides default paths";
let fixture = null;
try {
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 silenceStderr(() =>
loadSuppressionConfig(null, { enabled: true })
);
if (
config.source === fixture.path &&
config.suppressions.length === 1 &&
config.suppressions[0].checkId === "ENV-001"
) {
pass(testName);
} else {
fail(testName, `Unexpected config: ${JSON.stringify(config)}`);
}
});
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: missing suppressions array fails
// -----------------------------------------------------------------------------
async function testMissingSuppressions() {
const testName = "loadSuppressionConfig: missing suppressions array fails";
let fixture = null;
try {
const configWithoutSuppressions = JSON.stringify({
enabledFor: ["audit"],
note: "This config is missing the suppressions array",
});
fixture = await withTempFile(configWithoutSuppressions);
try {
await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
fail(testName, "Expected error for missing suppressions array");
} catch (err) {
if (err.message.includes("missing 'suppressions' array")) {
pass(testName);
} else {
fail(testName, `Wrong error message: ${err.message}`);
}
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: empty suppressions array is valid
// -----------------------------------------------------------------------------
async function testEmptySuppressions() {
const testName = "loadSuppressionConfig: empty suppressions array is valid";
let fixture = null;
try {
const emptyConfig = makeConfig([], ["audit"]);
fixture = await withTempFile(emptyConfig);
const config = await silenceStderr(() =>
loadSuppressionConfig(fixture.path, { enabled: true })
);
if (config.source === fixture.path && config.suppressions.length === 0) {
pass(testName);
} else {
fail(testName, `Unexpected config: ${JSON.stringify(config)}`);
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.cleanup();
}
}
}
// -----------------------------------------------------------------------------
// Test: custom path not found throws error
// -----------------------------------------------------------------------------
async function testCustomPathNotFoundFails() {
const testName = "loadSuppressionConfig: custom path not found throws error";
try {
const nonExistentPath = path.join(os.tmpdir(), "absolutely-does-not-exist-12345.json");
try {
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")) {
pass(testName);
} else {
fail(testName, `Wrong error message: ${err.message}`);
}
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// 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
// -----------------------------------------------------------------------------
async function runTests() {
console.log("=== OpenClaw Audit Watchdog - Suppression Config Tests ===\n");
await testValidConfig();
await testMalformedDateWarning();
await testMissingRequiredField();
await testMalformedJSON();
await testFileNotFoundGracefulFallback();
await testCustomPathPriority();
await testEnvironmentVariableOverride();
await testMissingSuppressions();
await testEmptySuppressions();
await testCustomPathNotFoundFails();
await testDisabledByDefault();
await testEnabledExplicitly();
await testEnvVarAloneDoesNotActivate();
await testMissingSentinel();
await testWrongSentinel();
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
if (failCount > 0) {
process.exit(1);
}
}
runTests().catch((error) => {
console.error("Test runner failed:", error);
process.exit(1);
});