mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
7cdb4ab7e2
* docs: add agent collaboration and git safety rules to AGENTS.md
* fix(portability): harden cross-platform path handling and install workflows
- add shared path resolution utility for advisory guardian components
- expand and normalize home-path tokens: ~, $HOME, ${HOME}, %USERPROFILE%, $env:USERPROFILE
- reject unresolved/escaped home tokens to prevent literal "$HOME" directory creation
- fix install/runtime path handling in:
- openclaw-audit-watchdog setup_cron and suppression config loader
- clawsec-suite advisory hook handler, suppression loader, and guarded installer
- remove hardcoded Homebrew binary assumptions in watchdog scripts/tests
- add LF enforcement via .gitattributes to reduce CRLF script breakage
- expand CI Node checks to linux/macos/windows matrix
- add cross-platform test coverage for path expansion and token rejection
- update README and SKILL docs with bash/zsh/PowerShell-safe path guidance
- add compatibility deliverables:
- docs/COMPATIBILITY_REPORT.md
- docs/REMEDIATION_PLAN.md
- docs/PLATFORM_VERIFICATION.md
Validation:
- node skills/clawsec-suite/test/path_resolution.test.mjs
- node skills/clawsec-suite/test/guarded_install.test.mjs
- node skills/clawsec-suite/test/advisory_suppression.test.mjs
- node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
- node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs
* fix(advisory): avoid fail-open on invalid path vars and cover watchdog tests
* docs: move signing runbooks into docs folder
* docs: remove root-level signing runbooks after move
* chore(clawsec-suite): bump version to 0.1.3
* chore(openclaw-audit-watchdog): bump version to 0.1.1
* docs(changelog): add entries for clawsec-suite 0.1.3 and watchdog 0.1.1
* docs(changelog): credit @aldodelgado for PR #62 contributions
* feat(clawsec-suite): scope advisories to openclaw application
* fix(ci): run advisory scope tests without TypeScript loader
---------
Co-authored-by: David Abutbul <David.a@prompt.security>
430 lines
14 KiB
JavaScript
430 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";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "guarded_skill_install.mjs");
|
|
|
|
let tempDir;
|
|
let passCount = 0;
|
|
let failCount = 0;
|
|
|
|
function pass(name) {
|
|
passCount++;
|
|
console.log(`✓ ${name}`);
|
|
}
|
|
|
|
function fail(name, error) {
|
|
failCount++;
|
|
console.error(`✗ ${name}`);
|
|
console.error(` ${String(error)}`);
|
|
}
|
|
|
|
function generateEd25519KeyPair() {
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
|
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
return { publicKeyPem, privateKeyPem };
|
|
}
|
|
|
|
function signPayload(data, privateKeyPem) {
|
|
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
|
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
|
|
return signature.toString("base64");
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
});
|