mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
63de5ce08d
* auto-claude: subtask-1-1 - Create config loading utility with multi-path fallback Created load_suppression_config.mjs with: - Multi-path fallback: ~/.openclaw/security-audit.json -> .clawsec/allowlist.json - Environment variable support (OPENCLAW_AUDIT_CONFIG) - Custom path support via CLI argument - Schema validation (checkId, skill, reason, suppressedAt required) - Malformed JSON error handling - Graceful fallback to empty suppressions when no config exists - ISO 8601 date format validation with warnings Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-2 - Create example config file template - Added security-audit-config.example.json with two suppression examples - Included examples for clawsec-suite and openclaw-audit-watchdog - Created comprehensive README.md explaining configuration format - All required fields documented (checkId, skill, reason, suppressedAt) - ISO 8601 date format demonstrated - JSON validated successfully * auto-claude: subtask-1-3 - Add unit tests for config loading Added comprehensive unit tests for suppression config loading: - Valid config with all required fields - Malformed date warning (non-blocking) - Missing required field validation - Malformed JSON error handling - File not found graceful fallback - Custom path priority - Environment variable override - Missing/empty suppressions array handling All 10 tests passing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-1 - Add suppression filtering to render_report.mjs Implements suppression filtering logic for security audit findings: - Import loadSuppressionConfig for config loading - Add --config CLI argument for custom config paths - Create extractSkillName() to extract skill names from findings (tries multiple fields) - Create filterFindings() to split findings into active/suppressed - Match suppressions by BOTH checkId AND skill name (exact match required) - Attach suppression metadata (reason, suppressedAt) to suppressed findings - Modify render() to accept suppressedFindings parameter - Apply filtering in main execution before rendering Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-2 - Add INFO-SUPPRESSED section to report output - Added lineForSuppressedFinding() to format suppressed findings - Added INFO-SUPPRESSED section showing suppressed findings with reason and date - Suppressed findings are not counted in summary (already filtered) - Follows existing code patterns for report sections Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-1 - Add --config flag to run_audit_and_format.sh - Added --config flag to accept path to config file - Added --help flag with usage documentation - Config flag is passed to openclaw audit commands when provided - Follows existing pattern for --label flag * auto-claude: subtask-4-1 - Create integration tests for render_report with suppressions Created comprehensive integration tests covering: - Suppressed findings appear in INFO-SUPPRESSED section - Active findings appear in CRITICAL/WARN section - Summary counts exclude suppressed findings - Backward compatibility (no config) - Partial matches don't suppress (checkId or skill alone) - Multiple suppressions work correctly - Skill name extraction from path field - Skill name extraction from title field - Empty suppressions array behaves like no config Bug fix in render_report.mjs: - Summary counts now recalculated after filtering suppressed findings - Previously summary showed original counts instead of filtered counts All 10 tests passing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-4-2 - Manual E2E test with real openclaw audit - Fixed run_audit_and_format.sh to pass --config flag to render_report.mjs - Enhanced lineForFinding() to display skill names for better clarity - Enhanced lineForSuppressedFinding() to display skill names consistently - Created comprehensive E2E test documentation in E2E-TEST-RESULTS.md - All E2E verification points passed: * Config loading from custom paths * Suppression matching by checkId + skill name * INFO-SUPPRESSED section display * Suppression reason and date display * Summary count accuracy (excludes suppressed findings) * Non-suppressed findings preservation * Skill name display in all findings - All integration tests still passing (10/10) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * auto-claude: subtask-5-1 - Update README.md with suppression feature * auto-claude: subtask-5-2 - Update SKILL.md with usage examples * - Add backslash escaping before quote escaping in oneline() function - Prevents incomplete string escaping vulnerability - Resolves CodeQL alert: https://github.com/prompt-security/clawsec/security/code-scanning/16 * Fix regex in extractSkillName function and simplify error handling in suppression config tests * Enhance suppression mechanism in OpenClaw Audit Watchdog - Updated README.md to clarify suppression configuration and activation requirements. - Improved SKILL.md with examples for suppressing known findings. - Refactored load_suppression_config.mjs to implement opt-in gating for suppressions. - Modified render_report.mjs to support suppression flag in report generation. - Enhanced run_audit_and_format.sh and runner.sh scripts to accept --enable-suppressions flag. - Added test cases for suppression configuration, including validation for enabledFor sentinel and opt-in behavior. - Introduced new test files for empty and invalid suppression configurations. * Fix type assertion for checksums file entries in Checksums component * Update ESLint configuration and dependencies to pin @eslint/js to version 9.28.0 * Update CHANGELOG.md for advisory suppression module and OpenClaw Audit Watchdog enhancements * Refactor finding comparison logic in render_report.mjs to simplify equality checks * chore(clawsec-suite): bump version to 0.1.2 * chore(openclaw-audit-watchdog): bump version to 0.1.0 * Remove suppressed matches tracking from state to prevent re-evaluation alerts --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
688 lines
20 KiB
JavaScript
Executable File
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);
|
|
});
|