mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
c9a66d5c99
* refactor: extract shared test harness module from 9 test files Extract duplicated test utilities into a reusable test_harness.mjs module to eliminate ~200-250 lines of boilerplate code across test files. Changes: - Create skills/clawsec-suite/test/lib/test_harness.mjs with: - Test reporting: pass(), fail(), report(), exitWithResults() - Crypto utilities: generateEd25519KeyPair(), signPayload() - Temp directory: createTempDir() with cleanup - Environment helpers: withEnv() for isolated env vars - Test runner factory: createTestRunner() for isolated counters - Refactor 9 test files to use shared harness: - feed_verification.test.mjs - guarded_install.test.mjs - skill_catalog_discovery.test.mjs - advisory_suppression.test.mjs - advisory_application_scope.test.mjs - path_resolution.test.mjs - fuzz_properties.test.mjs - suppression_config.test.mjs - render_report_suppression.test.mjs Benefits: - Single source of truth for test utilities - Consistent test reporting across all files - Easier to add new test files - Reduced maintenance burden Verification: - All 80 tests pass (15+8+3+15+4+6+1+17+11) - Zero ESLint warnings - No behavior changes - only code deduplication - Cross-skill module sharing works (openclaw-audit-watchdog → clawsec-suite) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: update minimatch override to 10.2.4 to resolve ReDoS vulnerabilities Bump minimatch from 10.2.1 to 10.2.4 in overrides to fix 10 high-severity ReDoS vulnerabilities (GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74). Also add .venv/ to ESLint ignores to prevent linting Python venv files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
409 lines
14 KiB
JavaScript
409 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Guarded skill install tests for clawsec-suite.
|
|
*
|
|
* Tests cover:
|
|
* - Conservative matching when version is omitted
|
|
* - Precise version matching when version is provided
|
|
* - Exit code 42 for advisory match requiring confirmation
|
|
* - High-risk advisory detection
|
|
*
|
|
* Run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
|
*/
|
|
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
pass,
|
|
fail,
|
|
report,
|
|
exitWithResults,
|
|
generateEd25519KeyPair,
|
|
signPayload,
|
|
} from "./lib/test_harness.mjs";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "guarded_skill_install.mjs");
|
|
|
|
let tempDir;
|
|
|
|
function createFeed(advisories) {
|
|
return JSON.stringify(
|
|
{
|
|
version: "1.0.0",
|
|
updated: "2026-02-08T12:00:00Z",
|
|
advisories,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
function createChecksumManifest(files) {
|
|
const checksums = {};
|
|
for (const [name, content] of Object.entries(files)) {
|
|
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
|
|
}
|
|
return JSON.stringify(
|
|
{
|
|
schema_version: "1.0",
|
|
algorithm: "sha256",
|
|
files: checksums,
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
async function setupTestDir() {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-install-test-"));
|
|
}
|
|
|
|
async function cleanupTestDir() {
|
|
if (tempDir) {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function setupSignedFeed(advisories, keyPair) {
|
|
const feedContent = createFeed(advisories);
|
|
const feedSignature = signPayload(feedContent, keyPair.privateKeyPem);
|
|
|
|
const checksumManifest = createChecksumManifest({
|
|
"feed.json": feedContent,
|
|
"feed.json.sig": feedSignature + "\n",
|
|
"feed-signing-public.pem": keyPair.publicKeyPem,
|
|
});
|
|
const checksumSignature = signPayload(checksumManifest, keyPair.privateKeyPem);
|
|
|
|
const advisoriesDir = path.join(tempDir, "advisories");
|
|
await fs.mkdir(advisoriesDir, { recursive: true });
|
|
|
|
await fs.writeFile(path.join(advisoriesDir, "feed.json"), feedContent);
|
|
await fs.writeFile(path.join(advisoriesDir, "feed.json.sig"), feedSignature + "\n");
|
|
await fs.writeFile(path.join(advisoriesDir, "checksums.json"), checksumManifest);
|
|
await fs.writeFile(path.join(advisoriesDir, "checksums.json.sig"), checksumSignature + "\n");
|
|
await fs.writeFile(path.join(advisoriesDir, "feed-signing-public.pem"), keyPair.publicKeyPem);
|
|
|
|
return advisoriesDir;
|
|
}
|
|
|
|
function runGuardedInstall(args, env) {
|
|
return new Promise((resolve) => {
|
|
const proc = spawn("node", [SCRIPT_PATH, ...args], {
|
|
env: { ...process.env, ...env },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
proc.stdout.on("data", (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
proc.stderr.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
proc.on("close", (code) => {
|
|
resolve({ code, stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test: Conservative matching when version is omitted
|
|
// -----------------------------------------------------------------------------
|
|
async function testConservativeMatchingWithoutVersion() {
|
|
const testName = "guarded_install: conservative matching without version triggers advisory";
|
|
try {
|
|
const keyPair = generateEd25519KeyPair();
|
|
const advisoriesDir = await setupSignedFeed(
|
|
[
|
|
{
|
|
id: "TEST-001",
|
|
severity: "high",
|
|
affected: ["test-skill@1.0.0", "test-skill@1.0.1"],
|
|
},
|
|
],
|
|
keyPair,
|
|
);
|
|
|
|
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
|
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
|
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
|
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
|
CLAWSEC_FEED_URL: "file:///nonexistent", // Force local fallback
|
|
});
|
|
|
|
if (result.code === 42 && result.stdout.includes("Conservative")) {
|
|
pass(testName);
|
|
} else {
|
|
fail(testName, `Expected exit 42 with conservative message, got ${result.code}: ${result.stdout}`);
|
|
}
|
|
} catch (error) {
|
|
fail(testName, error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test: Precise version matching
|
|
// -----------------------------------------------------------------------------
|
|
async function testPreciseVersionMatching() {
|
|
const testName = "guarded_install: precise version matching only matches exact version";
|
|
try {
|
|
const keyPair = generateEd25519KeyPair();
|
|
const advisoriesDir = await setupSignedFeed(
|
|
[
|
|
{
|
|
id: "TEST-001",
|
|
severity: "high",
|
|
affected: ["test-skill@1.0.0"],
|
|
},
|
|
],
|
|
keyPair,
|
|
);
|
|
|
|
// Version 2.0.0 should NOT match advisory for 1.0.0
|
|
const result = await runGuardedInstall(
|
|
["--skill", "test-skill", "--version", "2.0.0", "--dry-run"],
|
|
{
|
|
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
|
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
|
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
|
},
|
|
);
|
|
|
|
if (result.code === 0 && !result.stdout.includes("Advisory matches")) {
|
|
pass(testName);
|
|
} else {
|
|
fail(testName, `Expected exit 0 without match, got ${result.code}: ${result.stdout}`);
|
|
}
|
|
} catch (error) {
|
|
fail(testName, error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test: Version match triggers confirmation requirement
|
|
// -----------------------------------------------------------------------------
|
|
async function testVersionMatchTriggersConfirmation() {
|
|
const testName = "guarded_install: matching version triggers exit 42";
|
|
try {
|
|
const keyPair = generateEd25519KeyPair();
|
|
const advisoriesDir = await setupSignedFeed(
|
|
[
|
|
{
|
|
id: "TEST-001",
|
|
severity: "high",
|
|
affected: ["test-skill@1.0.0"],
|
|
},
|
|
],
|
|
keyPair,
|
|
);
|
|
|
|
const result = await runGuardedInstall(
|
|
["--skill", "test-skill", "--version", "1.0.0", "--dry-run"],
|
|
{
|
|
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
|
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
|
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
|
},
|
|
);
|
|
|
|
if (result.code === 42 && result.stdout.includes("Advisory matches")) {
|
|
pass(testName);
|
|
} else {
|
|
fail(testName, `Expected exit 42 with advisory match, got ${result.code}: ${result.stdout}`);
|
|
}
|
|
} catch (error) {
|
|
fail(testName, error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test: --confirm-advisory allows proceeding
|
|
// -----------------------------------------------------------------------------
|
|
async function testConfirmAdvisoryAllowsProceeding() {
|
|
const testName = "guarded_install: --confirm-advisory with --dry-run proceeds";
|
|
try {
|
|
const keyPair = generateEd25519KeyPair();
|
|
const advisoriesDir = await setupSignedFeed(
|
|
[
|
|
{
|
|
id: "TEST-001",
|
|
severity: "high",
|
|
affected: ["test-skill@1.0.0"],
|
|
},
|
|
],
|
|
keyPair,
|
|
);
|
|
|
|
const result = await runGuardedInstall(
|
|
["--skill", "test-skill", "--version", "1.0.0", "--confirm-advisory", "--dry-run"],
|
|
{
|
|
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
|
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
|
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
|
},
|
|
);
|
|
|
|
if (result.code === 0 && result.stdout.includes("Dry run")) {
|
|
pass(testName);
|
|
} else {
|
|
fail(testName, `Expected exit 0 with dry run message, got ${result.code}: ${result.stdout}`);
|
|
}
|
|
} catch (error) {
|
|
fail(testName, error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test: allowUnsigned bypass warning
|
|
// -----------------------------------------------------------------------------
|
|
async function testAllowUnsignedWarning() {
|
|
const testName = "guarded_install: CLAWSEC_ALLOW_UNSIGNED_FEED shows warning";
|
|
try {
|
|
// Create unsigned feed (no signatures)
|
|
const feedContent = createFeed([]);
|
|
const feedPath = path.join(tempDir, "unsigned-feed.json");
|
|
await fs.writeFile(feedPath, feedContent);
|
|
|
|
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
|
CLAWSEC_LOCAL_FEED: feedPath,
|
|
CLAWSEC_ALLOW_UNSIGNED_FEED: "1",
|
|
CLAWSEC_VERIFY_CHECKSUM_MANIFEST: "0",
|
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
|
});
|
|
|
|
if (result.stderr.includes("CLAWSEC_ALLOW_UNSIGNED_FEED")) {
|
|
pass(testName);
|
|
} else {
|
|
fail(testName, `Expected unsigned mode warning, got: ${result.stderr}`);
|
|
}
|
|
} catch (error) {
|
|
fail(testName, error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test: Missing signature fails without allowUnsigned
|
|
// -----------------------------------------------------------------------------
|
|
async function testMissingSignatureFails() {
|
|
const testName = "guarded_install: missing signature fails without allowUnsigned";
|
|
try {
|
|
const feedContent = createFeed([]);
|
|
const feedPath = path.join(tempDir, "nosig-feed.json");
|
|
await fs.writeFile(feedPath, feedContent);
|
|
|
|
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
|
CLAWSEC_LOCAL_FEED: feedPath,
|
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
|
});
|
|
|
|
if (result.code === 1) {
|
|
pass(testName);
|
|
} else {
|
|
fail(testName, `Expected exit 1 for missing signature, got ${result.code}`);
|
|
}
|
|
} catch (error) {
|
|
fail(testName, error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test: $HOME path expansion for local feed paths
|
|
// -----------------------------------------------------------------------------
|
|
async function testHomeExpansionForLocalFeedPaths() {
|
|
const testName = "guarded_install: expands $HOME in local feed env paths";
|
|
try {
|
|
const keyPair = generateEd25519KeyPair();
|
|
await setupSignedFeed([], keyPair);
|
|
|
|
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
|
HOME: tempDir,
|
|
CLAWSEC_LOCAL_FEED: "$HOME/advisories/feed.json",
|
|
CLAWSEC_LOCAL_FEED_SIG: "$HOME/advisories/feed.json.sig",
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: "$HOME/advisories/checksums.json",
|
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: "$HOME/advisories/checksums.json.sig",
|
|
CLAWSEC_FEED_PUBLIC_KEY: "$HOME/advisories/feed-signing-public.pem",
|
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
|
});
|
|
|
|
if (result.code === 0 && result.stdout.includes("Advisory source: local:")) {
|
|
pass(testName);
|
|
} else {
|
|
fail(testName, `Expected local feed success, got ${result.code}: ${result.stdout} ${result.stderr}`);
|
|
}
|
|
} catch (error) {
|
|
fail(testName, error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test: escaped home token is rejected
|
|
// -----------------------------------------------------------------------------
|
|
async function testEscapedHomeTokenRejected() {
|
|
const testName = "guarded_install: escaped $HOME token is rejected";
|
|
try {
|
|
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
|
CLAWSEC_LOCAL_FEED: "\\$HOME/advisories/feed.json",
|
|
});
|
|
|
|
if (result.code === 1 && result.stderr.includes("Unexpanded home token")) {
|
|
pass(testName);
|
|
} else {
|
|
fail(testName, `Expected token validation error, got ${result.code}: ${result.stderr || result.stdout}`);
|
|
}
|
|
} catch (error) {
|
|
fail(testName, error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Main test runner
|
|
// -----------------------------------------------------------------------------
|
|
async function runTests() {
|
|
console.log("=== ClawSec Guarded Install Tests ===\n");
|
|
|
|
await setupTestDir();
|
|
|
|
try {
|
|
await testConservativeMatchingWithoutVersion();
|
|
await testPreciseVersionMatching();
|
|
await testVersionMatchTriggersConfirmation();
|
|
await testConfirmAdvisoryAllowsProceeding();
|
|
await testAllowUnsignedWarning();
|
|
await testMissingSignatureFails();
|
|
await testHomeExpansionForLocalFeedPaths();
|
|
await testEscapedHomeTokenRejected();
|
|
} finally {
|
|
await cleanupTestDir();
|
|
}
|
|
|
|
report();
|
|
exitWithResults();
|
|
}
|
|
|
|
runTests().catch((error) => {
|
|
console.error("Test runner failed:", error);
|
|
process.exit(1);
|
|
});
|