Files
clawsec/skills/clawsec-suite/test/advisory_suppression.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

369 lines
11 KiB
JavaScript

#!/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);
});