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:
davida-ps
2026-06-07 13:00:56 +03:00
committed by GitHub
parent 11f0fc50c4
commit 3cef7aa46b
18 changed files with 593 additions and 480 deletions
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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();
+6
View File
@@ -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 -1
View File
@@ -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));
}
+127 -20
View File
@@ -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 -1
View File
@@ -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');
});
+5
View File
@@ -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
+14 -14
View File
@@ -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),
};
+53 -229
View File
@@ -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();
}
+4 -4
View File
@@ -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",
+104 -21
View File
@@ -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();