diff --git a/skills/clawsec-clawhub-checker/CHANGELOG.md b/skills/clawsec-clawhub-checker/CHANGELOG.md index 07a7ea6..e4a2859 100644 --- a/skills/clawsec-clawhub-checker/CHANGELOG.md +++ b/skills/clawsec-clawhub-checker/CHANGELOG.md @@ -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 diff --git a/skills/clawsec-clawhub-checker/SKILL.md b/skills/clawsec-clawhub-checker/SKILL.md index 7d173c9..fb9e7cb 100644 --- a/skills/clawsec-clawhub-checker/SKILL.md +++ b/skills/clawsec-clawhub-checker/SKILL.md @@ -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: diff --git a/skills/clawsec-clawhub-checker/scripts/check_clawhub_reputation.mjs b/skills/clawsec-clawhub-checker/scripts/check_clawhub_reputation.mjs index 1bb44fa..562ace8 100644 --- a/skills/clawsec-clawhub-checker/scripts/check_clawhub_reputation.mjs +++ b/skills/clawsec-clawhub-checker/scripts/check_clawhub_reputation.mjs @@ -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; diff --git a/skills/clawsec-clawhub-checker/skill.json b/skills/clawsec-clawhub-checker/skill.json index 152e70a..7686890 100644 --- a/skills/clawsec-clawhub-checker/skill.json +++ b/skills/clawsec-clawhub-checker/skill.json @@ -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", diff --git a/skills/clawsec-clawhub-checker/test/reputation_check.test.mjs b/skills/clawsec-clawhub-checker/test/reputation_check.test.mjs index 7b94ae0..67acd29 100644 --- a/skills/clawsec-clawhub-checker/test/reputation_check.test.mjs +++ b/skills/clawsec-clawhub-checker/test/reputation_check.test.mjs @@ -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(); diff --git a/skills/clawsec-nanoclaw/CHANGELOG.md b/skills/clawsec-nanoclaw/CHANGELOG.md index 695bafb..2c8951f 100644 --- a/skills/clawsec-nanoclaw/CHANGELOG.md +++ b/skills/clawsec-nanoclaw/CHANGELOG.md @@ -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 diff --git a/skills/clawsec-nanoclaw/SKILL.md b/skills/clawsec-nanoclaw/SKILL.md index 1e393c2..bb23fbf 100644 --- a/skills/clawsec-nanoclaw/SKILL.md +++ b/skills/clawsec-nanoclaw/SKILL.md @@ -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 --- diff --git a/skills/clawsec-nanoclaw/host-services/integrity-handler.ts b/skills/clawsec-nanoclaw/host-services/integrity-handler.ts index 060b6ee..4922908 100644 --- a/skills/clawsec-nanoclaw/host-services/integrity-handler.ts +++ b/skills/clawsec-nanoclaw/host-services/integrity-handler.ts @@ -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 { 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)); } diff --git a/skills/clawsec-nanoclaw/lib/advisories.ts b/skills/clawsec-nanoclaw/lib/advisories.ts index efd6db3..4fb3560 100644 --- a/skills/clawsec-nanoclaw/lib/advisories.ts +++ b/skills/clawsec-nanoclaw/lib/advisories.ts @@ -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; } /** diff --git a/skills/clawsec-nanoclaw/skill.json b/skills/clawsec-nanoclaw/skill.json index 27a04f3..cbb0f71 100644 --- a/skills/clawsec-nanoclaw/skill.json +++ b/skills/clawsec-nanoclaw/skill.json @@ -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", diff --git a/skills/clawsec-nanoclaw/test/security-hardening.test.mjs b/skills/clawsec-nanoclaw/test/security-hardening.test.mjs index 867a4e1..494aeff 100644 --- a/skills/clawsec-nanoclaw/test/security-hardening.test.mjs +++ b/skills/clawsec-nanoclaw/test/security-hardening.test.mjs @@ -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'); +}); diff --git a/skills/clawsec-scanner/CHANGELOG.md b/skills/clawsec-scanner/CHANGELOG.md index cd6075d..747f861 100644 --- a/skills/clawsec-scanner/CHANGELOG.md +++ b/skills/clawsec-scanner/CHANGELOG.md @@ -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 diff --git a/skills/clawsec-scanner/SKILL.md b/skills/clawsec-scanner/SKILL.md index 04a4fce..03227ea 100644 --- a/skills/clawsec-scanner/SKILL.md +++ b/skills/clawsec-scanner/SKILL.md @@ -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 diff --git a/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts b/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts index 1ec3d96..a4bdab3 100644 --- a/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts +++ b/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts @@ -196,7 +196,7 @@ function buildAlertMessage(report: ScanReport, format: string): string { } const handler = async (event: HookEvent, _context: HookContext): Promise => { - // 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; } diff --git a/skills/clawsec-scanner/scripts/dast_hook_executor.mjs b/skills/clawsec-scanner/scripts/dast_hook_executor.mjs index 27d3372..3913132 100644 --- a/skills/clawsec-scanner/scripts/dast_hook_executor.mjs +++ b/skills/clawsec-scanner/scripts/dast_hook_executor.mjs @@ -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), }; diff --git a/skills/clawsec-scanner/scripts/dast_runner.mjs b/skills/clawsec-scanner/scripts/dast_runner.mjs index cd1d49f..dc3e1c4 100755 --- a/skills/clawsec-scanner/scripts/dast_runner.mjs +++ b/skills/clawsec-scanner/scripts/dast_runner.mjs @@ -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 = [ - "", - "'; 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} - */ -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} event - * @param {Record} context * @param {number} timeoutMs * @returns {Promise} */ -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}} + * @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} */ -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(); } diff --git a/skills/clawsec-scanner/skill.json b/skills/clawsec-scanner/skill.json index 8616a1b..0fbaa92 100644 --- a/skills/clawsec-scanner/skill.json +++ b/skills/clawsec-scanner/skill.json @@ -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", diff --git a/skills/clawsec-scanner/test/dast_harness.test.mjs b/skills/clawsec-scanner/test/dast_harness.test.mjs index 5a87d6b..face5c4 100644 --- a/skills/clawsec-scanner/test/dast_harness.test.mjs +++ b/skills/clawsec-scanner/test/dast_harness.test.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("