mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
fix(security): harden high scan findings (#258)
* fix(security): harden high scan findings * fix(security): tighten review hardening * fix(nanoclaw): preserve prerelease advisory matching
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.5] - 2026-06-07
|
||||
|
||||
### Security
|
||||
- Treat explicit malicious ClawHub and VirusTotal verdicts as blocking signals regardless of the numeric reputation score.
|
||||
|
||||
## [0.0.4] - 2026-05-13
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-clawhub-checker
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
description: ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
|
||||
@@ -35,6 +35,12 @@ function blockOnMissingScannerData(result, warning) {
|
||||
result.blocked = true;
|
||||
}
|
||||
|
||||
function blockOnMaliciousScannerData(result, warning) {
|
||||
result.warnings.push(warning);
|
||||
result.score = 0;
|
||||
result.blocked = true;
|
||||
}
|
||||
|
||||
function parseJson(raw, label, warnings) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
@@ -58,7 +64,10 @@ function maybeApplyVersionSecuritySignals(result, versionDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof security.status === "string" && security.status.toLowerCase() === "suspicious") {
|
||||
const securityStatus = typeof security.status === "string" ? security.status.toLowerCase() : "";
|
||||
if (securityStatus === "malicious") {
|
||||
blockOnMaliciousScannerData(result, "ClawHub static moderation marked the version as malicious");
|
||||
} else if (securityStatus === "suspicious") {
|
||||
result.warnings.push("ClawHub static moderation marked the version as suspicious");
|
||||
result.score -= 30;
|
||||
}
|
||||
@@ -82,7 +91,15 @@ function maybeApplyVersionSecuritySignals(result, versionDetails) {
|
||||
"";
|
||||
const normalizedStatus = vtStatus.toLowerCase();
|
||||
|
||||
if (normalizedStatus === "suspicious") {
|
||||
if (normalizedStatus === "malicious") {
|
||||
result.virustotal.push("ClawHub VirusTotal scan returned malicious");
|
||||
blockOnMaliciousScannerData(result, "ClawHub VirusTotal scan returned malicious");
|
||||
|
||||
const vtSummary = typeof vt.analysis === "string" ? vt.analysis.trim() : "";
|
||||
if (vtSummary) {
|
||||
result.virustotal.push(vtSummary.split("\n")[0]);
|
||||
}
|
||||
} else if (normalizedStatus === "suspicious") {
|
||||
result.virustotal.push("ClawHub VirusTotal scan returned suspicious");
|
||||
result.score -= 40;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-clawhub-checker",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"description": "ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.",
|
||||
"author": "abutbul",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
@@ -58,6 +60,37 @@ function runScript(scriptPath, args, env) {
|
||||
});
|
||||
}
|
||||
|
||||
async function createMockClawhub(payload) {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawhub-reputation-test-"));
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
const mockPath = path.join(binDir, "clawhub");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
mockPath,
|
||||
`#!/usr/bin/env node
|
||||
const payload = ${JSON.stringify(JSON.stringify(payload))};
|
||||
const command = process.argv[2] || "";
|
||||
if (command === "inspect") {
|
||||
process.stdout.write(payload);
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "search") {
|
||||
process.stdout.write("name\\nmock-skill\\nother-skill\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("unexpected clawhub command: " + process.argv.slice(2).join(" ") + "\\n");
|
||||
process.exit(2);
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(mockPath, 0o755);
|
||||
|
||||
return {
|
||||
env: { PATH: `${binDir}:${process.env.PATH}` },
|
||||
cleanup: async () => fs.rm(tmpDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid skill slug is rejected (command injection prevention)
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -208,6 +241,59 @@ async function testPreReleaseVersionAccepted() {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Explicit malicious scanner verdict blocks regardless of score
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testMaliciousVirusTotalVerdictBlocks() {
|
||||
const testName = "reputation_check: malicious VirusTotal verdict blocks install";
|
||||
const now = Date.now();
|
||||
const mock = await createMockClawhub({
|
||||
skill: {
|
||||
createdAt: now - (120 * 24 * 60 * 60 * 1000),
|
||||
updatedAt: now - (2 * 24 * 60 * 60 * 1000),
|
||||
stats: { downloads: 1000 },
|
||||
},
|
||||
owner: { handle: "trusted-publisher" },
|
||||
version: {
|
||||
security: {
|
||||
status: "clean",
|
||||
scanners: {
|
||||
vt: {
|
||||
normalizedStatus: "malicious",
|
||||
analysis: "malicious verdict from scanner",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['malicious-skill', '1.0.0', '70'], mock.env);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.code === 43 &&
|
||||
parsed.safe === false &&
|
||||
parsed.warnings.some((w) => w.toLowerCase().includes("malicious")) &&
|
||||
parsed.virustotal.some((v) => v.toLowerCase().includes("malicious"))
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected malicious verdict to block, got code ${result.code}: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
await mock.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: CLI entrypoint guard works when script path is relative
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -411,6 +497,7 @@ async function runTests() {
|
||||
await testUppercaseSlugRejected();
|
||||
await testEmptySlugShowsUsage();
|
||||
await testPreReleaseVersionAccepted();
|
||||
await testMaliciousVirusTotalVerdictBlocks();
|
||||
await testRelativePathCliEntrypointWorks();
|
||||
await testInvalidThresholdRejected();
|
||||
await testEnhancedInstallerRejectsInvalidSkill();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.7] - 2026-06-07
|
||||
|
||||
### Security
|
||||
- Added comparator range support for NanoClaw advisory matching and fail-closed handling for malformed affected specifiers.
|
||||
- Added strict integrity IPC request ID validation and result path containment before host-side result writes.
|
||||
|
||||
## [0.0.6] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-nanoclaw
|
||||
version: 0.0.6
|
||||
version: 0.0.7
|
||||
description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { IntegrityMonitor } from '../guardian/integrity-monitor';
|
||||
|
||||
const RESULT_DIR = '/workspace/ipc/clawsec_results';
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
// ============================================================================
|
||||
// Integrity Service (Singleton)
|
||||
// ============================================================================
|
||||
@@ -84,15 +87,21 @@ export async function handleIntegrityIpc(
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { type, requestId, groupFolder: _groupFolder } = task;
|
||||
const validatedRequestId = validateRequestId(requestId);
|
||||
|
||||
if (!validatedRequestId) {
|
||||
logger.warn({ type, requestId }, 'Invalid integrity IPC request id');
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTask = { ...task, requestId: validatedRequestId };
|
||||
|
||||
if (!deps.integrityService) {
|
||||
logger.warn({ task }, 'IntegrityService not available');
|
||||
if (requestId) {
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: 'IntegrityService not initialized'
|
||||
});
|
||||
}
|
||||
writeResult(validatedRequestId, {
|
||||
success: false,
|
||||
error: 'IntegrityService not initialized'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,31 +112,29 @@ export async function handleIntegrityIpc(
|
||||
await service.initialize();
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to initialize IntegrityService');
|
||||
if (requestId) {
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
writeResult(validatedRequestId, {
|
||||
success: false,
|
||||
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'integrity_check':
|
||||
await handleIntegrityCheck(task, service, logger);
|
||||
await handleIntegrityCheck(safeTask, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_approve':
|
||||
await handleIntegrityApprove(task, service, logger);
|
||||
await handleIntegrityApprove(safeTask, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_status':
|
||||
await handleIntegrityStatus(task, service, logger);
|
||||
await handleIntegrityStatus(safeTask, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_verify_audit':
|
||||
await handleIntegrityVerifyAudit(task, service, logger);
|
||||
await handleIntegrityVerifyAudit(safeTask, service, logger);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -280,15 +287,40 @@ async function handleIntegrityVerifyAudit(
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function validateRequestId(requestId: unknown): string | null {
|
||||
if (typeof requestId !== 'string') return null;
|
||||
const normalized = requestId.trim();
|
||||
if (!REQUEST_ID_PATTERN.test(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveResultPath(requestId: string): string {
|
||||
const safeRequestId = validateRequestId(requestId);
|
||||
if (!safeRequestId) {
|
||||
throw new Error('Invalid integrity IPC request id');
|
||||
}
|
||||
|
||||
const resultDir = RESULT_DIR;
|
||||
const normalizedResultDir = path.resolve(resultDir);
|
||||
const resultPath = path.resolve(normalizedResultDir, `${safeRequestId}.json`);
|
||||
const relativePath = path.relative(normalizedResultDir, resultPath);
|
||||
|
||||
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
throw new Error('Integrity IPC result path escapes result directory');
|
||||
}
|
||||
|
||||
return resultPath;
|
||||
}
|
||||
|
||||
function writeResult(requestId: string, result: any): void {
|
||||
const resultDir = '/workspace/ipc/clawsec_results';
|
||||
const resultPath = resolveResultPath(requestId);
|
||||
const resultDir = path.dirname(resultPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(resultDir)) {
|
||||
fs.mkdirSync(resultDir, { recursive: true });
|
||||
}
|
||||
|
||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -86,39 +86,146 @@ export function versionMatches(version: string, versionSpec: string): boolean {
|
||||
if (v === spec) return true;
|
||||
|
||||
// Parse semver components
|
||||
const parseVersion = (ver: string): number[] => {
|
||||
const match = ver.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) return [];
|
||||
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
||||
type ParsedVersion = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
prerelease: string[];
|
||||
};
|
||||
|
||||
const semverPattern = String.raw`v?\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`;
|
||||
const semverRegex = new RegExp(
|
||||
String.raw`^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`
|
||||
);
|
||||
|
||||
const parseVersion = (ver: string): ParsedVersion | null => {
|
||||
const match = ver.match(semverRegex);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
major: parseInt(match[1], 10),
|
||||
minor: parseInt(match[2], 10),
|
||||
patch: parseInt(match[3], 10),
|
||||
prerelease: match[4] ? match[4].split('.') : [],
|
||||
};
|
||||
};
|
||||
|
||||
const comparePrereleaseIdentifiers = (left: string, right: string): number => {
|
||||
const leftIsNumeric = /^\d+$/.test(left);
|
||||
const rightIsNumeric = /^\d+$/.test(right);
|
||||
|
||||
if (leftIsNumeric && rightIsNumeric) {
|
||||
const leftValue = parseInt(left, 10);
|
||||
const rightValue = parseInt(right, 10);
|
||||
if (leftValue > rightValue) return 1;
|
||||
if (leftValue < rightValue) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftIsNumeric) return -1;
|
||||
if (rightIsNumeric) return 1;
|
||||
if (left > right) return 1;
|
||||
if (left < right) return -1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const compareVersions = (left: ParsedVersion, right: ParsedVersion): number => {
|
||||
if (left.major > right.major) return 1;
|
||||
if (left.major < right.major) return -1;
|
||||
if (left.minor > right.minor) return 1;
|
||||
if (left.minor < right.minor) return -1;
|
||||
if (left.patch > right.patch) return 1;
|
||||
if (left.patch < right.patch) return -1;
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const identifierCount = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let index = 0; index < identifierCount; index += 1) {
|
||||
const leftIdentifier = left.prerelease[index];
|
||||
const rightIdentifier = right.prerelease[index];
|
||||
|
||||
if (leftIdentifier === undefined) return -1;
|
||||
if (rightIdentifier === undefined) return 1;
|
||||
|
||||
const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier);
|
||||
if (comparison !== 0) return comparison;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const evaluateComparator = (comparator: string): boolean => {
|
||||
const match = comparator.trim().match(new RegExp(`^(<=|>=|<|>|=)?\\s*(${semverPattern})$`));
|
||||
if (!match) return false;
|
||||
|
||||
const operator = match[1] || '=';
|
||||
const comparatorParts = parseVersion(match[2]);
|
||||
if (!comparatorParts) return false;
|
||||
|
||||
const comparison = compareVersions(vParts, comparatorParts);
|
||||
if (operator === '<') return comparison < 0;
|
||||
if (operator === '<=') return comparison <= 0;
|
||||
if (operator === '>') return comparison > 0;
|
||||
if (operator === '>=') return comparison >= 0;
|
||||
return comparison === 0;
|
||||
};
|
||||
|
||||
const extractComparatorTokens = (range: string): string[] | null => {
|
||||
const tokenPattern = new RegExp(`(?:<=|>=|<|>|=)?\\s*${semverPattern}`, 'g');
|
||||
const tokens: string[] = [];
|
||||
let cursor = 0;
|
||||
let match = tokenPattern.exec(range);
|
||||
|
||||
while (match) {
|
||||
const gap = range.slice(cursor, match.index);
|
||||
if (!/^[\s,]*$/.test(gap)) return null;
|
||||
|
||||
tokens.push(match[0].trim());
|
||||
cursor = match.index + match[0].length;
|
||||
match = tokenPattern.exec(range);
|
||||
}
|
||||
|
||||
if (!/^[\s,]*$/.test(range.slice(cursor))) return null;
|
||||
return tokens.length > 0 ? tokens : null;
|
||||
};
|
||||
|
||||
const vParts = parseVersion(v);
|
||||
const specParts = parseVersion(spec.replace(/^[~^]/, ''));
|
||||
if (!vParts) return true;
|
||||
|
||||
if (vParts.length === 0 || specParts.length === 0) return false;
|
||||
if (/(?:<=|>=|<|>|=)/.test(spec)) {
|
||||
const comparatorTokens = extractComparatorTokens(spec);
|
||||
if (!comparatorTokens) return false;
|
||||
return comparatorTokens.every((token) => evaluateComparator(token));
|
||||
}
|
||||
|
||||
const specParts = parseVersion(spec.replace(/^[~^]/, ''));
|
||||
if (!specParts) return true;
|
||||
|
||||
// Caret range (^1.2.3): compatible with 1.x.x where x >= 2.3
|
||||
if (spec.startsWith('^')) {
|
||||
if (vParts[0] !== specParts[0]) return false;
|
||||
if (vParts[0] === 0) {
|
||||
// ^0.2.3 means 0.2.x where x >= 3
|
||||
if (vParts[1] !== specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
}
|
||||
// ^1.2.3 means 1.x.x where x.x >= 2.3
|
||||
if (vParts[1] > specParts[1]) return true;
|
||||
if (vParts[1] < specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
const upperBound =
|
||||
specParts.major > 0
|
||||
? { major: specParts.major + 1, minor: 0, patch: 0, prerelease: [] }
|
||||
: specParts.minor > 0
|
||||
? { major: 0, minor: specParts.minor + 1, patch: 0, prerelease: [] }
|
||||
: { major: 0, minor: 0, patch: specParts.patch + 1, prerelease: [] };
|
||||
|
||||
return compareVersions(vParts, specParts) >= 0 && compareVersions(vParts, upperBound) < 0;
|
||||
}
|
||||
|
||||
// Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3)
|
||||
if (spec.startsWith('~')) {
|
||||
if (vParts[0] !== specParts[0]) return false;
|
||||
if (vParts[1] !== specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
const upperBound = { major: specParts.major, minor: specParts.minor + 1, patch: 0, prerelease: [] };
|
||||
return compareVersions(vParts, specParts) >= 0 && compareVersions(vParts, upperBound) < 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
if (new RegExp(`^${semverPattern}$`).test(spec)) {
|
||||
return compareVersions(vParts, specParts) === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-nanoclaw",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"description": "ClawSec security suite for NanoClaw - Advisory feed monitoring, MCP tools for vulnerability checking, and Ed25519 signature verification for containerized WhatsApp bot agents",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -12,6 +14,45 @@ function readSkillFile(relativePath) {
|
||||
return fs.readFileSync(path.join(SKILL_ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function extractFunctionSource(source, functionName) {
|
||||
const marker = `export function ${functionName}`;
|
||||
const start = source.indexOf(marker);
|
||||
assert.notEqual(start, -1, `missing ${functionName} export`);
|
||||
|
||||
const bodyStart = source.indexOf('{', start);
|
||||
assert.notEqual(bodyStart, -1, `missing ${functionName} body`);
|
||||
|
||||
let depth = 0;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === '{') depth += 1;
|
||||
if (char === '}') depth -= 1;
|
||||
if (depth === 0) {
|
||||
return source.slice(start, index + 1).replace('export ', '');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unterminated ${functionName} body`);
|
||||
}
|
||||
|
||||
function loadVersionMatcher() {
|
||||
const source = readSkillFile('lib/advisories.ts');
|
||||
const fnSource = extractFunctionSource(source, 'versionMatches');
|
||||
const js = ts.transpileModule(
|
||||
`${fnSource}\nglobalThis.versionMatches = versionMatches;`,
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}
|
||||
).outputText;
|
||||
|
||||
const context = { globalThis: {} };
|
||||
vm.runInNewContext(js, context);
|
||||
return context.globalThis.versionMatches;
|
||||
}
|
||||
|
||||
test('signature verifier enforces pinned key and path policy', () => {
|
||||
const source = readSkillFile('host-services/skill-signature-handler.ts');
|
||||
|
||||
@@ -55,3 +96,39 @@ test('integrity targets and baselines use normalized absolute paths', () => {
|
||||
assert.ok(source.includes('const normalizedFilePath = path.resolve(filePath);'), 'status/approval lookups must normalize file paths');
|
||||
assert.ok(source.includes('normalizedFiles[path.resolve(filePath)] = baseline;'), 'loaded baselines must be normalized to absolute keys');
|
||||
});
|
||||
|
||||
test('advisory matcher handles comparator ranges and fails closed on malformed specs', () => {
|
||||
const versionMatches = loadVersionMatcher();
|
||||
|
||||
assert.equal(versionMatches('2026.4.20', '<2026.5.18'), true, 'less-than comparator must match vulnerable versions');
|
||||
assert.equal(versionMatches('2026.5.18', '<2026.5.18'), false, 'less-than comparator must exclude patched versions');
|
||||
assert.equal(versionMatches('2026.5.18', '<=2026.5.18'), true, 'less-than-or-equal comparator must match boundary versions');
|
||||
assert.equal(versionMatches('1.4.0', '>=1.2.0 <2.0.0'), true, 'composite comparator ranges must match all clauses');
|
||||
assert.equal(versionMatches('2.0.0', '>=1.2.0 <2.0.0'), false, 'composite comparator ranges must reject failed clauses');
|
||||
assert.equal(versionMatches('0.0.2', '<= 0.0.2'), true, 'spaced comparators must match boundary versions');
|
||||
assert.equal(versionMatches('0.0.3', '<= 0.0.2'), false, 'spaced comparators must reject versions outside range');
|
||||
assert.equal(versionMatches('1.2.3', '>= 1.0.0 <'), false, 'partially parsed comparator ranges must not match everything');
|
||||
assert.equal(versionMatches('1.2.3', 'not-a-range'), true, 'unparseable advisory specifiers must fail closed');
|
||||
});
|
||||
|
||||
test('advisory matcher preserves semver prerelease precedence', () => {
|
||||
const versionMatches = loadVersionMatcher();
|
||||
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '1.2.3'), false, 'prereleases must not collapse into releases');
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '=1.2.3'), false, 'explicit equality must honor prerelease data');
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '<1.2.3'), true, 'prereleases must compare lower than releases');
|
||||
assert.equal(versionMatches('1.2.3', '>1.2.3-beta.1'), true, 'releases must compare higher than prereleases');
|
||||
assert.equal(versionMatches('1.2.3-beta.2', '<1.2.3-beta.10'), true, 'numeric prerelease identifiers must compare numerically');
|
||||
assert.equal(versionMatches('1.2.3+build.1', '=1.2.3+build.2'), true, 'build metadata must not affect precedence');
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '^1.2.3'), false, 'caret lower bounds must honor prerelease precedence');
|
||||
assert.equal(versionMatches('1.2.3-beta.1', '~1.2.3'), false, 'tilde lower bounds must honor prerelease precedence');
|
||||
});
|
||||
|
||||
test('integrity IPC result writer validates request ids and result path containment', () => {
|
||||
const source = readSkillFile('host-services/integrity-handler.ts');
|
||||
|
||||
assert.ok(source.includes('validateRequestId(requestId)'), 'writeResult must validate request ids before writing');
|
||||
assert.ok(source.includes('resolveResultPath(requestId)'), 'writeResult must resolve result paths through a boundary helper');
|
||||
assert.ok(source.includes('path.resolve(resultDir)'), 'result directory must be normalized before containment checks');
|
||||
assert.ok(source.includes('path.relative(normalizedResultDir, resultPath)'), 'result path must be compared relative to the intended directory');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.4] - 2026-06-07
|
||||
|
||||
### Security
|
||||
- Replaced DAST target hook execution with static hook source inspection so scanner runs never import, transpile, or invoke untrusted handler code.
|
||||
|
||||
## [0.0.3] - 2026-05-13
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: clawsec-scanner
|
||||
version: 0.0.3
|
||||
description: Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific DAST hook execution testing for OpenClaw hooks.
|
||||
version: 0.0.4
|
||||
description: Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific static hook inspection for OpenClaw hooks.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "🔍"
|
||||
@@ -16,7 +16,7 @@ Comprehensive security scanner for agent platforms that automates vulnerability
|
||||
- **Dependency Scanning**: Analyzes npm and Python dependencies using `npm audit` and `pip-audit` with structured JSON output parsing
|
||||
- **CVE Database Integration**: Queries OSV (primary), NVD 2.0, and GitHub Advisory Database for vulnerability enrichment
|
||||
- **SAST Analysis**: Static code analysis using Semgrep (JavaScript/TypeScript) and Bandit (Python) to detect hardcoded secrets, command injection, path traversal, and unsafe deserialization
|
||||
- **DAST Framework**: Agent-specific dynamic analysis with real OpenClaw hook execution harness (malicious input, timeout, output bounds, event mutation safety)
|
||||
- **DAST Framework**: Agent-specific static analysis of OpenClaw hook metadata and handler source without importing or invoking target code
|
||||
- **Unified Reporting**: Consolidated vulnerability reports with severity classification and remediation guidance
|
||||
- **Continuous Monitoring**: OpenClaw hook integration for automated periodic scanning
|
||||
|
||||
@@ -43,8 +43,8 @@ The scanner orchestrates four complementary scan types to provide comprehensive
|
||||
- Identifies: hardcoded secrets (API keys, tokens), command injection (`eval`, `exec`), path traversal, unsafe deserialization
|
||||
|
||||
4. **Dynamic Analysis (DAST)**
|
||||
- Real hook execution harness for OpenClaw hook handlers discovered from `HOOK.md` metadata
|
||||
- Verifies: malicious input resilience, timeout behavior, output amplification bounds, and core event mutation safety
|
||||
- Static hook inspection for OpenClaw hook handlers discovered from `HOOK.md` metadata
|
||||
- Verifies coverage and source-level risk signals without importing, transpiling, or invoking target handlers
|
||||
- Note: Traditional web DAST tools (ZAP, Burp) do not apply to agent platforms - this provides agent-specific testing
|
||||
|
||||
### Unified Reporting
|
||||
@@ -248,8 +248,8 @@ scripts/runner.sh # Orchestration layer
|
||||
├── scan_dependencies.mjs # npm audit + pip-audit
|
||||
├── query_cve_databases.mjs # OSV/NVD/GitHub API queries
|
||||
├── sast_analyzer.mjs # Semgrep + Bandit static analysis
|
||||
├── dast_runner.mjs # Dynamic security testing orchestration
|
||||
└── dast_hook_executor.mjs # Isolated real hook execution harness
|
||||
├── dast_runner.mjs # Static hook inspection orchestration
|
||||
└── dast_hook_executor.mjs # Static hook source inspection helper
|
||||
|
||||
lib/
|
||||
├── report.mjs # Result aggregation and formatting
|
||||
@@ -326,10 +326,10 @@ proc.on('close', code => {
|
||||
- Requires Python 3.8+ runtime
|
||||
- Alternative: use Docker image `returntocorp/semgrep`
|
||||
|
||||
**"TypeScript hook not executable in DAST harness"**
|
||||
- The DAST harness executes real hook handlers and transpiles `handler.ts` files when a TypeScript compiler is available
|
||||
- Install TypeScript in the scanner environment: `npm install -D typescript` (or provide `handler.js`/`handler.mjs`)
|
||||
- Without a compiler, scanner reports an `info`-level coverage finding instead of a high-severity vulnerability
|
||||
**"DAST static coverage finding"**
|
||||
- The DAST harness does not execute target hook handlers.
|
||||
- JavaScript and TypeScript hook files are read as source and reported with `info`-level static coverage findings.
|
||||
- Review any listed static signals manually when deciding whether a hook needs deeper sandboxed testing.
|
||||
|
||||
**"Concurrent scan detected"**
|
||||
- Lockfile exists: `/tmp/clawsec-scanner.lock`
|
||||
@@ -371,7 +371,7 @@ done
|
||||
node test/dependency_scanner.test.mjs # Dependency scanning
|
||||
node test/cve_integration.test.mjs # CVE database APIs
|
||||
node test/sast_engine.test.mjs # Static analysis
|
||||
node test/dast_harness.test.mjs # DAST harness execution
|
||||
node test/dast_harness.test.mjs # DAST static hook inspection
|
||||
```
|
||||
|
||||
### Linting
|
||||
@@ -456,11 +456,11 @@ npx clawhub@latest install clawsec-suite
|
||||
|
||||
## Roadmap
|
||||
|
||||
### v0.0.2 (Current)
|
||||
### v0.0.4 (Current)
|
||||
- [x] Dependency scanning (npm audit, pip-audit)
|
||||
- [x] CVE database integration (OSV, NVD, GitHub Advisory)
|
||||
- [x] SAST analysis (Semgrep, Bandit)
|
||||
- [x] Real OpenClaw hook execution harness for DAST
|
||||
- [x] Static OpenClaw hook inspection for DAST without target code execution
|
||||
- [x] Unified JSON reporting
|
||||
- [x] OpenClaw hook integration
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ function buildAlertMessage(report: ScanReport, format: string): string {
|
||||
}
|
||||
|
||||
const handler = async (event: HookEvent, _context: HookContext): Promise<void> => {
|
||||
// DAST harness mode executes hook handlers directly; skip recursive scanner runs.
|
||||
// Preserve the legacy DAST guard so older scanner harnesses cannot recurse.
|
||||
if (process.env.CLAWSEC_DAST_HARNESS === "1" || _context?.dastMode === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
@@ -47,26 +45,9 @@ function parseArgs(argv) {
|
||||
throw new Error("Missing required --handler");
|
||||
}
|
||||
|
||||
if (!parsed.eventB64) {
|
||||
throw new Error("Missing required --event");
|
||||
}
|
||||
|
||||
if (!parsed.contextB64) {
|
||||
throw new Error("Missing required --context");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function decodeBase64Json(value, label) {
|
||||
try {
|
||||
const decoded = Buffer.from(value, "base64").toString("utf8");
|
||||
return JSON.parse(decoded);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decode ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
@@ -76,69 +57,7 @@ async function fileExists(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTypeScriptCompiler() {
|
||||
if (process.env.CLAWSEC_DAST_DISABLE_TYPESCRIPT === "1") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const imported = await import("typescript");
|
||||
return imported.default || imported;
|
||||
} catch {
|
||||
// Ignore and try require path next.
|
||||
}
|
||||
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
return req("typescript");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function importTypeScriptModule(tsPath) {
|
||||
const tsCompiler = await loadTypeScriptCompiler();
|
||||
if (!tsCompiler || typeof tsCompiler.transpileModule !== "function") {
|
||||
throw new Error(
|
||||
`Cannot execute TypeScript hook (${tsPath}): typescript compiler not available. ` +
|
||||
"Install 'typescript' or provide a JavaScript handler file.",
|
||||
);
|
||||
}
|
||||
|
||||
const source = await fs.readFile(tsPath, "utf8");
|
||||
const transpiled = tsCompiler.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: tsCompiler.ModuleKind.ESNext,
|
||||
target: tsCompiler.ScriptTarget.ES2022,
|
||||
moduleResolution: tsCompiler.ModuleResolutionKind.NodeNext,
|
||||
esModuleInterop: true,
|
||||
sourceMap: false,
|
||||
inlineSourceMap: false,
|
||||
declaration: false,
|
||||
},
|
||||
fileName: tsPath,
|
||||
reportDiagnostics: false,
|
||||
});
|
||||
|
||||
const tempFile = path.join(
|
||||
path.dirname(tsPath),
|
||||
`.clawsec-dast-${path.basename(tsPath, ".ts")}-${process.pid}-${Date.now()}.mjs`,
|
||||
);
|
||||
|
||||
await fs.writeFile(tempFile, transpiled.outputText, "utf8");
|
||||
|
||||
try {
|
||||
return await import(`${pathToFileURL(tempFile).href}?ts=${Date.now()}`);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(tempFile);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHookModule(handlerPath) {
|
||||
async function readHookSource(handlerPath) {
|
||||
const fullPath = path.resolve(handlerPath);
|
||||
const exists = await fileExists(fullPath);
|
||||
if (!exists) {
|
||||
@@ -146,120 +65,71 @@ async function loadHookModule(handlerPath) {
|
||||
}
|
||||
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
|
||||
if (ext === ".ts") {
|
||||
return importTypeScriptModule(fullPath);
|
||||
const allowedExtensions = new Set([".cjs", ".js", ".mjs", ".ts"]);
|
||||
if (!allowedExtensions.has(ext)) {
|
||||
throw new Error(`Unsupported hook handler extension: ${ext || "(none)"}`);
|
||||
}
|
||||
|
||||
return import(`${pathToFileURL(fullPath).href}?v=${Date.now()}`);
|
||||
const source = await fs.readFile(fullPath, "utf8");
|
||||
return { fullPath, ext, source };
|
||||
}
|
||||
|
||||
function resolveHandlerExport(mod, exportName) {
|
||||
function detectHandlerExport(source, exportName) {
|
||||
if (exportName && exportName !== "default") {
|
||||
if (typeof mod?.[exportName] === "function") {
|
||||
return mod[exportName];
|
||||
}
|
||||
throw new Error(`Hook export '${exportName}' is not a function`);
|
||||
const escaped = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`export\\s+(?:async\\s+)?function\\s+${escaped}\\b|export\\s*\\{[^}]*\\b${escaped}\\b`, "m").test(source);
|
||||
}
|
||||
|
||||
if (typeof mod?.default === "function") {
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
if (typeof mod?.handler === "function") {
|
||||
return mod.handler;
|
||||
}
|
||||
|
||||
throw new Error("Hook module does not export a handler function");
|
||||
return (
|
||||
/\bexport\s+default\b/m.test(source) ||
|
||||
/\bexport\s+(?:async\s+)?function\s+handler\b/m.test(source) ||
|
||||
/\bmodule\.exports\s*=|\bexports\.handler\s*=/m.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(event) {
|
||||
const timestamp = event?.timestamp;
|
||||
if (typeof timestamp === "string" || typeof timestamp === "number") {
|
||||
const parsed = new Date(timestamp);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
event.timestamp = parsed;
|
||||
function collectRiskSignals(source) {
|
||||
const rules = [
|
||||
["child_process", /\bchild_process\b|\bfrom\s+["']node:child_process["']|\brequire\(["']child_process["']\)/m],
|
||||
["dynamic-import", /\bimport\s*\(/m],
|
||||
["eval", /\beval\s*\(|\bnew\s+Function\s*\(/m],
|
||||
["shell-command", /\b(?:exec|spawn|execFile|fork)\s*\(/m],
|
||||
["environment-access", /\bprocess\.env\b/m],
|
||||
];
|
||||
|
||||
const signals = [];
|
||||
for (const [name, pattern] of rules) {
|
||||
if (pattern.test(source)) {
|
||||
signals.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMessages(messages) {
|
||||
if (!Array.isArray(messages)) {
|
||||
return {
|
||||
count: 0,
|
||||
charCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let charCount = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
if (typeof message === "string") {
|
||||
charCount += message.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
charCount += JSON.stringify(message).length;
|
||||
} catch {
|
||||
charCount += 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: messages.length,
|
||||
charCount,
|
||||
};
|
||||
}
|
||||
|
||||
function coreEventShape(event) {
|
||||
return {
|
||||
type: event?.type ?? null,
|
||||
action: event?.action ?? null,
|
||||
sessionKey: event?.sessionKey ?? null,
|
||||
};
|
||||
return signals;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const event = decodeBase64Json(args.eventB64, "event payload");
|
||||
const context = decodeBase64Json(args.contextB64, "context payload");
|
||||
|
||||
normalizeTimestamp(event);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const before = coreEventShape(event);
|
||||
|
||||
try {
|
||||
const mod = await loadHookModule(args.handler);
|
||||
const handler = resolveHandlerExport(mod, args.exportName);
|
||||
|
||||
await handler(event, context);
|
||||
|
||||
const after = coreEventShape(event);
|
||||
const messageSummary = summarizeMessages(event?.messages);
|
||||
const inspected = await readHookSource(args.handler);
|
||||
|
||||
const payload = {
|
||||
ok: true,
|
||||
static_only: true,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
core_before: before,
|
||||
core_after: after,
|
||||
messages_count: messageSummary.count,
|
||||
messages_char_count: messageSummary.charCount,
|
||||
handler_path: inspected.fullPath,
|
||||
handler_extension: inspected.ext,
|
||||
source_bytes: Buffer.byteLength(inspected.source, "utf8"),
|
||||
source_lines: inspected.source.split(/\r?\n/).length,
|
||||
handler_export_declared: detectHandlerExport(inspected.source, args.exportName),
|
||||
risk_signals: collectRiskSignals(inspected.source),
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
const after = coreEventShape(event);
|
||||
const messageSummary = summarizeMessages(event?.messages);
|
||||
|
||||
const payload = {
|
||||
ok: false,
|
||||
static_only: true,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
core_before: before,
|
||||
core_after: after,
|
||||
messages_count: messageSummary.count,
|
||||
messages_char_count: messageSummary.charCount,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ import { getTimestamp } from "../lib/utils.mjs";
|
||||
*/
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const MAX_OUTPUT_MESSAGES = 25;
|
||||
const MAX_OUTPUT_CHARS = 20000;
|
||||
const SKIP_DIR_NAMES = new Set([
|
||||
".git",
|
||||
".github",
|
||||
@@ -38,17 +36,6 @@ const SKIP_DIR_NAMES = new Set([
|
||||
".openclaw",
|
||||
]);
|
||||
|
||||
const MALICIOUS_PAYLOADS = [
|
||||
"<script>alert('XSS')</script>",
|
||||
"'; DROP TABLE users; --",
|
||||
"; rm -rf /",
|
||||
"$(whoami)",
|
||||
"..\\..\\..\\windows\\system32\\config\\sam",
|
||||
"../../../etc/passwd",
|
||||
"test\0malicious",
|
||||
"A".repeat(200000),
|
||||
];
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const HOOK_EXECUTOR_PATH = path.join(__dirname, "dast_hook_executor.mjs");
|
||||
@@ -320,43 +307,6 @@ export async function discoverHooks(targetPath) {
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventKey
|
||||
* @returns {{type: string, action: string}}
|
||||
*/
|
||||
function splitEventKey(eventKey) {
|
||||
const parts = String(eventKey ?? "").split(":");
|
||||
const type = parts.shift() || "command";
|
||||
const action = parts.join(":") || "new";
|
||||
return { type, action };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventKey
|
||||
* @param {string} payload
|
||||
* @param {string} targetPath
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function buildEvent(eventKey, payload, targetPath) {
|
||||
const { type, action } = splitEventKey(eventKey);
|
||||
|
||||
return {
|
||||
type,
|
||||
action,
|
||||
sessionKey: "clawsec-dast-session",
|
||||
timestamp: new Date().toISOString(),
|
||||
messages: [],
|
||||
context: {
|
||||
content: payload,
|
||||
transcript: payload,
|
||||
workspaceDir: path.resolve(targetPath),
|
||||
channelId: "dast-harness",
|
||||
commandSource: "dast",
|
||||
bootstrapFiles: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} HarnessInvocationResult
|
||||
* @property {boolean} timedOut
|
||||
@@ -368,33 +318,24 @@ export function buildEvent(eventKey, payload, targetPath) {
|
||||
|
||||
/**
|
||||
* @param {HookDescriptor} hook
|
||||
* @param {Record<string, unknown>} event
|
||||
* @param {Record<string, unknown>} context
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<HarnessInvocationResult>}
|
||||
*/
|
||||
async function invokeHookHarness(hook, event, context, timeoutMs) {
|
||||
const encodedEvent = Buffer.from(JSON.stringify(event), "utf8").toString("base64");
|
||||
const encodedContext = Buffer.from(JSON.stringify(context), "utf8").toString("base64");
|
||||
|
||||
async function inspectHookHandler(hook, timeoutMs) {
|
||||
const args = [
|
||||
HOOK_EXECUTOR_PATH,
|
||||
"--handler",
|
||||
hook.handlerPath,
|
||||
"--export",
|
||||
hook.exportName || "default",
|
||||
"--event",
|
||||
encodedEvent,
|
||||
"--context",
|
||||
encodedContext,
|
||||
];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("node", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWSEC_DAST_HARNESS: "1",
|
||||
PATH: process.env.PATH || "",
|
||||
CLAWSEC_DAST_STATIC_INSPECTION: "1",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -462,31 +403,33 @@ function isObject(value) {
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
* @returns {{ok: boolean, error: string, messagesCount: number, messagesCharCount: number, coreAfter: Record<string, unknown>}}
|
||||
* @returns {{ok: boolean, error: string, staticOnly: boolean, riskSignals: string[], handlerExportDeclared: boolean}}
|
||||
*/
|
||||
function normalizeHarnessPayload(parsed) {
|
||||
function normalizeStaticPayload(parsed) {
|
||||
if (!isObject(parsed)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Harness output is not an object",
|
||||
messagesCount: 0,
|
||||
messagesCharCount: 0,
|
||||
coreAfter: {},
|
||||
staticOnly: false,
|
||||
riskSignals: [],
|
||||
handlerExportDeclared: false,
|
||||
};
|
||||
}
|
||||
|
||||
const ok = parsed.ok === true;
|
||||
const error = typeof parsed.error === "string" ? parsed.error : "";
|
||||
const messagesCount = Number(parsed.messages_count ?? 0) || 0;
|
||||
const messagesCharCount = Number(parsed.messages_char_count ?? 0) || 0;
|
||||
const coreAfter = isObject(parsed.core_after) ? parsed.core_after : {};
|
||||
const staticOnly = parsed.static_only === true;
|
||||
const riskSignals = Array.isArray(parsed.risk_signals)
|
||||
? parsed.risk_signals.filter((signal) => typeof signal === "string")
|
||||
: [];
|
||||
const handlerExportDeclared = parsed.handler_export_declared === true;
|
||||
|
||||
return {
|
||||
ok,
|
||||
error,
|
||||
messagesCount,
|
||||
messagesCharCount,
|
||||
coreAfter,
|
||||
staticOnly,
|
||||
riskSignals,
|
||||
handlerExportDeclared,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,19 +445,6 @@ function slug(input) {
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} reason
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isHarnessCapabilityError(reason) {
|
||||
const normalized = String(reason ?? "").toLowerCase();
|
||||
return (
|
||||
normalized.includes("typescript compiler not available")
|
||||
|| normalized.includes("does not export a handler function")
|
||||
|| normalized.includes("is not a function")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Vulnerability[]} bucket
|
||||
* @param {string} id
|
||||
@@ -541,178 +471,74 @@ function pushHookVulnerability(bucket, id, severity, hook, eventKey, title, desc
|
||||
|
||||
/**
|
||||
* @param {HookDescriptor} hook
|
||||
* @param {string} targetPath
|
||||
* @param {string} _targetPath
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<Vulnerability[]>}
|
||||
*/
|
||||
async function evaluateHook(hook, targetPath, timeoutMs) {
|
||||
async function evaluateHook(hook, _targetPath, timeoutMs) {
|
||||
const findings = [];
|
||||
const invocationTimeoutMs = Math.max(1000, timeoutMs);
|
||||
// Static inspection depends only on the handler source/export, so reuse it for all hook events.
|
||||
const inspection = await inspectHookHandler(hook, invocationTimeoutMs);
|
||||
|
||||
for (const eventKey of hook.events) {
|
||||
const safeEvent = buildEvent(eventKey, "safe baseline input", targetPath);
|
||||
const safeContext = {
|
||||
skillPath: hook.hookDir,
|
||||
agentPlatform: "openclaw",
|
||||
dastMode: true,
|
||||
targetPath: path.resolve(targetPath),
|
||||
event: eventKey,
|
||||
};
|
||||
|
||||
const safeResult = await invokeHookHarness(hook, safeEvent, safeContext, invocationTimeoutMs);
|
||||
|
||||
if (safeResult.timedOut) {
|
||||
if (inspection.timedOut) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"high",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook times out under baseline input",
|
||||
`Hook execution exceeded ${invocationTimeoutMs}ms for event '${eventKey}' under safe baseline input.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (safeResult.parseError) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-HARNESS-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
`DAST-STATIC-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"medium",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook harness output invalid",
|
||||
`Could not parse harness output for event '${eventKey}': ${safeResult.parseError}. stderr: ${safeResult.stderr || "(empty)"}`,
|
||||
"Hook static inspection timed out",
|
||||
`Static hook inspection exceeded ${invocationTimeoutMs}ms for event '${eventKey}'. Target code was not executed.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedSafe = normalizeHarnessPayload(safeResult.parsed);
|
||||
if (!normalizedSafe.ok) {
|
||||
const reason = normalizedSafe.error || safeResult.stderr || "unknown error";
|
||||
|
||||
if (isHarnessCapabilityError(reason)) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"info",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook not executable in local DAST harness",
|
||||
`DAST harness could not execute hook for event '${eventKey}' due to runtime capability limits: ${reason}`,
|
||||
);
|
||||
} else {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-CRASH-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"high",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook throws on baseline input",
|
||||
`Hook execution failed for event '${eventKey}' under safe baseline input: ${reason}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const mutationObserved =
|
||||
normalizedSafe.coreAfter.type !== safeEvent.type ||
|
||||
normalizedSafe.coreAfter.action !== safeEvent.action ||
|
||||
normalizedSafe.coreAfter.sessionKey !== safeEvent.sessionKey;
|
||||
|
||||
if (mutationObserved) {
|
||||
if (inspection.parseError) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-MUTATION-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"low",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook mutates core event identity fields",
|
||||
`Hook changed one or more of type/action/sessionKey for event '${eventKey}'. This can cause routing side effects in OpenClaw hooks.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedSafe.messagesCount > MAX_OUTPUT_MESSAGES ||
|
||||
normalizedSafe.messagesCharCount > MAX_OUTPUT_CHARS
|
||||
) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-OUTPUT-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
`DAST-STATIC-HARNESS-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"medium",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook output exceeds safe bounds",
|
||||
`Hook generated ${normalizedSafe.messagesCount} messages and ${normalizedSafe.messagesCharCount} chars for baseline input. Limits: ${MAX_OUTPUT_MESSAGES} messages / ${MAX_OUTPUT_CHARS} chars.`,
|
||||
"Hook static inspection output invalid",
|
||||
`Could not parse static inspection output for event '${eventKey}': ${inspection.parseError}. stderr: ${inspection.stderr || "(empty)"}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const maliciousFailures = [];
|
||||
const maliciousTimeouts = [];
|
||||
|
||||
for (const payload of MALICIOUS_PAYLOADS) {
|
||||
const event = buildEvent(eventKey, payload, targetPath);
|
||||
const context = {
|
||||
...safeContext,
|
||||
payloadLength: payload.length,
|
||||
};
|
||||
|
||||
const result = await invokeHookHarness(hook, event, context, invocationTimeoutMs);
|
||||
|
||||
if (result.timedOut) {
|
||||
maliciousTimeouts.push(`len=${payload.length}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.parseError) {
|
||||
maliciousFailures.push(`parse-error(${result.parseError})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizeHarnessPayload(result.parsed);
|
||||
if (!normalized.ok) {
|
||||
maliciousFailures.push(normalized.error || "execution-error");
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.messagesCount > MAX_OUTPUT_MESSAGES ||
|
||||
normalized.messagesCharCount > MAX_OUTPUT_CHARS
|
||||
) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-OUTPUT-${slug(`${hook.name}-${eventKey}`)}-${payload.length}`,
|
||||
"medium",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook output amplification under malicious input",
|
||||
`Hook generated ${normalized.messagesCount} messages and ${normalized.messagesCharCount} chars for payload length ${payload.length}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (maliciousTimeouts.length > 0) {
|
||||
const normalized = normalizeStaticPayload(inspection.parsed);
|
||||
if (!normalized.ok || !normalized.staticOnly) {
|
||||
const reason = normalized.error || inspection.stderr || "unknown static inspection error";
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-MALICIOUS-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"high",
|
||||
`DAST-STATIC-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"info",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook times out on malicious input",
|
||||
`Hook exceeded ${invocationTimeoutMs}ms for malicious payloads (${maliciousTimeouts.slice(0, 3).join(", ")}${maliciousTimeouts.length > 3 ? `, +${maliciousTimeouts.length - 3} more` : ""}).`,
|
||||
"Hook not executed during DAST static inspection",
|
||||
`DAST did not execute hook code for event '${eventKey}'. Static inspection failed with: ${reason}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (maliciousFailures.length > 0) {
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-MALICIOUS-CRASH-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"high",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook crashes on malicious input",
|
||||
`Hook raised unhandled errors for malicious payloads. Sample errors: ${maliciousFailures.slice(0, 3).join(" | ")}${maliciousFailures.length > 3 ? ` (+${maliciousFailures.length - 3} more)` : ""}`,
|
||||
);
|
||||
}
|
||||
const signalSuffix = normalized.riskSignals.length > 0
|
||||
? ` Static signals observed: ${normalized.riskSignals.join(", ")}.`
|
||||
: "";
|
||||
const exportSuffix = normalized.handlerExportDeclared
|
||||
? ""
|
||||
: " The configured handler export was not obvious from static source inspection.";
|
||||
|
||||
pushHookVulnerability(
|
||||
findings,
|
||||
`DAST-STATIC-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`,
|
||||
"info",
|
||||
hook,
|
||||
eventKey,
|
||||
"Hook inspected statically without executing target code",
|
||||
`DAST inspected the hook source for event '${eventKey}' without importing, transpiling, or invoking the handler.${signalSuffix}${exportSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
return findings;
|
||||
@@ -778,8 +604,6 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
export { MALICIOUS_PAYLOADS };
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clawsec-scanner",
|
||||
"version": "0.0.3",
|
||||
"description": "Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific DAST hook execution testing for OpenClaw hooks.",
|
||||
"version": "0.0.4",
|
||||
"description": "Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific static hook inspection for OpenClaw hooks.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
@@ -57,12 +57,12 @@
|
||||
{
|
||||
"path": "scripts/dast_runner.mjs",
|
||||
"required": true,
|
||||
"description": "Dynamic analysis harness executing OpenClaw hook handlers with malicious-input and timeout checks"
|
||||
"description": "Static OpenClaw hook inspection harness that does not execute target handlers"
|
||||
},
|
||||
{
|
||||
"path": "scripts/dast_hook_executor.mjs",
|
||||
"required": true,
|
||||
"description": "Isolated hook execution helper used by DAST for real OpenClaw harness testing"
|
||||
"description": "Static hook source inspection helper used by DAST without importing target handlers"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_scanner_hook.mjs",
|
||||
|
||||
@@ -89,8 +89,13 @@ metadata: { "openclaw": { "events": [${eventsLiteral}] } }
|
||||
await fs.writeFile(path.join(hookDir, handlerFile), handlerSource, "utf8");
|
||||
}
|
||||
|
||||
async function testSafeHookExecutesAndDoesNotReportMisleadingHigh() {
|
||||
const testName = "DAST harness: executes real hook and reports no misleading high findings";
|
||||
async function writeExecutable(filePath, content) {
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
await fs.chmod(filePath, 0o755);
|
||||
}
|
||||
|
||||
async function testSafeHookIsInspectedWithoutExecution() {
|
||||
const testName = "DAST harness: inspects hooks without executing target code";
|
||||
const tmp = await createTempDir();
|
||||
|
||||
try {
|
||||
@@ -125,19 +130,20 @@ export default handler;
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
const cleanSummary =
|
||||
const noHighSummary =
|
||||
result.report?.summary?.critical === 0
|
||||
&& result.report?.summary?.high === 0
|
||||
&& result.report?.summary?.medium === 0
|
||||
&& result.report?.summary?.low === 0
|
||||
&& result.report?.summary?.info === 0;
|
||||
&& result.report?.summary?.low === 0;
|
||||
const hasStaticCoverageInfo = Array.isArray(result.report?.vulnerabilities)
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-STATIC-COVERAGE"));
|
||||
|
||||
if (result.code === 0 && markerExists && cleanSummary) {
|
||||
if (result.code === 0 && !markerExists && noHighSummary && hasStaticCoverageInfo) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit=0, markerExists=true, clean summary. Got exit=${result.code}, markerExists=${markerExists}, summary=${JSON.stringify(result.report?.summary)} stderr=${result.stderr}`,
|
||||
`Expected exit=0, markerExists=false, static coverage info, and no high findings. Got exit=${result.code}, markerExists=${markerExists}, summary=${JSON.stringify(result.report?.summary)} findings=${JSON.stringify(result.report?.vulnerabilities || [])} stderr=${result.stderr}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -147,18 +153,24 @@ export default handler;
|
||||
}
|
||||
}
|
||||
|
||||
async function testMaliciousCrashProducesHighFinding() {
|
||||
const testName = "DAST harness: malicious input crash is reported as high";
|
||||
async function testMaliciousHandlerIsNotExecutedForPayloadChecks() {
|
||||
const testName = "DAST harness: malicious payload checks do not execute hook code";
|
||||
const tmp = await createTempDir();
|
||||
|
||||
try {
|
||||
const targetPath = path.join(tmp.path, "skill");
|
||||
const hookDir = path.join(targetPath, "hooks", "crashy-hook");
|
||||
const markerFile = path.join(hookDir, "executed.marker");
|
||||
|
||||
await writeHookFixture(
|
||||
hookDir,
|
||||
'"message:preprocessed"',
|
||||
`const handler = async (event) => {
|
||||
`import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
fs.writeFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), "executed.marker"), "top-level");
|
||||
|
||||
const handler = async (event) => {
|
||||
const payload = String(event?.context?.content || "");
|
||||
if (payload.includes("<script>")) {
|
||||
throw new Error("Unhandled payload path");
|
||||
@@ -170,16 +182,21 @@ export default handler;
|
||||
);
|
||||
|
||||
const result = await runDast(targetPath, 2500);
|
||||
const hasHigh = Number(result.report?.summary?.high || 0) > 0;
|
||||
const hasCrashFinding = Array.isArray(result.report?.vulnerabilities)
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-MALICIOUS-CRASH"));
|
||||
const markerExists = await fs
|
||||
.access(markerFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const noHigh = Number(result.report?.summary?.high || 0) === 0
|
||||
&& Number(result.report?.summary?.critical || 0) === 0;
|
||||
const hasStaticCoverageInfo = Array.isArray(result.report?.vulnerabilities)
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-STATIC-COVERAGE"));
|
||||
|
||||
if (result.code === 1 && hasHigh && hasCrashFinding) {
|
||||
if (result.code === 0 && !markerExists && noHigh && hasStaticCoverageInfo) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit=1 and malicious crash high finding. Got exit=${result.code}, summary=${JSON.stringify(result.report?.summary)}, findings=${JSON.stringify(result.report?.vulnerabilities || [])}`,
|
||||
`Expected static inspection without marker/high findings. Got exit=${result.code}, markerExists=${markerExists}, summary=${JSON.stringify(result.report?.summary)}, findings=${JSON.stringify(result.report?.vulnerabilities || [])}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -189,8 +206,8 @@ export default handler;
|
||||
}
|
||||
}
|
||||
|
||||
async function testMissingTypeScriptCompilerIsCoverageInfo() {
|
||||
const testName = "DAST harness: missing TypeScript compiler reports coverage info, not high";
|
||||
async function testTypeScriptHookIsStaticallyInspectedWithoutCompiler() {
|
||||
const testName = "DAST harness: TypeScript hooks are statically inspected without compiler execution";
|
||||
const tmp = await createTempDir();
|
||||
|
||||
try {
|
||||
@@ -220,7 +237,7 @@ export default handler;
|
||||
const noHigh = Number(result.report?.summary?.high || 0) === 0
|
||||
&& Number(result.report?.summary?.critical || 0) === 0;
|
||||
const hasCoverageInfo = Array.isArray(result.report?.vulnerabilities)
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-COVERAGE"));
|
||||
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-STATIC-COVERAGE"));
|
||||
const hasInfoCount = Number(result.report?.summary?.info || 0) > 0;
|
||||
|
||||
if (result.code === 0 && noHigh && hasCoverageInfo && hasInfoCount) {
|
||||
@@ -238,10 +255,76 @@ export default handler;
|
||||
}
|
||||
}
|
||||
|
||||
async function testStaticInspectionRunsOncePerHook() {
|
||||
const testName = "DAST harness: static inspection runs once per hook across events";
|
||||
const tmp = await createTempDir();
|
||||
|
||||
try {
|
||||
const targetPath = path.join(tmp.path, "skill");
|
||||
const hookDir = path.join(targetPath, "hooks", "multi-event-hook");
|
||||
const binDir = path.join(tmp.path, "bin");
|
||||
const nodeLogPath = path.join(tmp.path, "node-invocations.log");
|
||||
|
||||
await writeHookFixture(
|
||||
hookDir,
|
||||
'"agent:bootstrap", "command:new", "message:preprocessed"',
|
||||
`export default async function handler() {
|
||||
return;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeExecutable(
|
||||
path.join(binDir, "node"),
|
||||
`#!${process.execPath}
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
fs.appendFileSync(${JSON.stringify(nodeLogPath)}, JSON.stringify(process.argv.slice(2)) + "\\n");
|
||||
const result = spawnSync(${JSON.stringify(process.execPath)}, process.argv.slice(2), {
|
||||
env: process.env,
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
process.exit(result.status ?? 1);
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await runDast(targetPath, 2500, {
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
});
|
||||
|
||||
const log = await fs.readFile(nodeLogPath, "utf8");
|
||||
const invocations = log
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
const executorCount = invocations.filter((args) => String(args[0] || "").endsWith("dast_hook_executor.mjs")).length;
|
||||
const staticCoverageCount = Array.isArray(result.report?.vulnerabilities)
|
||||
? result.report.vulnerabilities.filter((v) => String(v.id || "").includes("DAST-STATIC-COVERAGE")).length
|
||||
: 0;
|
||||
|
||||
if (result.code === 0 && executorCount === 1 && staticCoverageCount === 3) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected one executor spawn and three per-event findings. Got exit=${result.code}, executorCount=${executorCount}, staticCoverageCount=${staticCoverageCount}, invocations=${JSON.stringify(invocations)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await testSafeHookExecutesAndDoesNotReportMisleadingHigh();
|
||||
await testMaliciousCrashProducesHighFinding();
|
||||
await testMissingTypeScriptCompilerIsCoverageInfo();
|
||||
await testSafeHookIsInspectedWithoutExecution();
|
||||
await testMaliciousHandlerIsNotExecutedForPayloadChecks();
|
||||
await testTypeScriptHookIsStaticallyInspectedWithoutCompiler();
|
||||
await testStaticInspectionRunsOncePerHook();
|
||||
|
||||
report();
|
||||
exitWithResults();
|
||||
|
||||
Reference in New Issue
Block a user