diff --git a/.github/workflows/skill-release.yml b/.github/workflows/skill-release.yml index 1af20c8..3c4c133 100644 --- a/.github/workflows/skill-release.yml +++ b/.github/workflows/skill-release.yml @@ -898,6 +898,9 @@ jobs: npx clawhub@latest install ${{ steps.parse.outputs.skill_name }} ``` + **If you already have `clawsec-suite` installed:** + Ask your agent to pull `${{ steps.parse.outputs.skill_name }}` from the ClawSec catalog and it will handle setup and verification automatically. + **Manual download with verification:** ```bash # 1. Download the release archive, checksums, and signing material diff --git a/README.md b/README.md index 676a0c7..bc716f9 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,9 @@ See [`skills/clawsec-nanoclaw/INSTALL.md`](skills/clawsec-nanoclaw/INSTALL.md) f The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and maintains security skills from the ClawSec catalog. -### Skills in the Suite +`clawsec-suite` is optional orchestration; skills can still be installed directly as standalone packages. + +### ClawSec Skills | Skill | Description | Installation | Compatibility | |-------|-------------|--------------|---------------| @@ -433,8 +435,9 @@ npm run build │ ├── populate-local-wiki.sh # Local wiki llms export populator │ └── release-skill.sh # Manual skill release helper ├── skills/ -│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills) +│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills - start here and have your agent do the rest) │ ├── clawsec-feed/ # 📡 Advisory feed skill +│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST) │ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite │ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks │ ├── clawtributor/ # 🤝 Community reporting skill diff --git a/skills/clawsec-scanner/CHANGELOG.md b/skills/clawsec-scanner/CHANGELOG.md index 90481ca..6053214 100644 --- a/skills/clawsec-scanner/CHANGELOG.md +++ b/skills/clawsec-scanner/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to the ClawSec Scanner will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.2] - 2026-03-10 + +### Changed + +- Replaced simulated DAST checks with real OpenClaw hook execution harness testing +- Updated DAST semantics so high-severity findings are emitted for actual hook execution failures/timeouts, not static payload pattern matches +- Reclassified DAST harness capability limitations (for example missing TypeScript compiler for `.ts` hooks) to `info` coverage findings instead of high severity +- Added DAST harness mode guard to prevent recursive scanner execution when hook handlers are tested in isolation + +### Added + +- New DAST helper executor script for isolated per-hook execution and timeout enforcement +- DAST harness regression tests covering no-false-positive baseline and malicious-input crash detection + ## [0.0.1] - 2026-02-27 ### Added diff --git a/skills/clawsec-scanner/SKILL.md b/skills/clawsec-scanner/SKILL.md index 5b83563..afc49d5 100644 --- a/skills/clawsec-scanner/SKILL.md +++ b/skills/clawsec-scanner/SKILL.md @@ -1,7 +1,7 @@ --- name: clawsec-scanner -version: 0.0.1 -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 basic DAST security testing for skill hooks. +version: 0.0.2 +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. 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**: Basic dynamic analysis for skill hook security testing (input validation, timeout enforcement) +- **DAST Framework**: Agent-specific dynamic analysis with real OpenClaw hook execution harness (malicious input, timeout, output bounds, event mutation safety) - **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)** - - Test framework for skill hook security validation - - Verifies: malicious input handling, timeout enforcement, resource limits + - 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 - Note: Traditional web DAST tools (ZAP, Burp) do not apply to agent platforms - this provides agent-specific testing ### Unified Reporting @@ -248,7 +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 +├── dast_runner.mjs # Dynamic security testing orchestration +└── dast_hook_executor.mjs # Isolated real hook execution harness lib/ ├── report.mjs # Result aggregation and formatting @@ -325,6 +326,11 @@ 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 + **"Concurrent scan detected"** - Lockfile exists: `/tmp/clawsec-scanner.lock` - Wait for running scan to complete or manually remove lockfile @@ -342,6 +348,7 @@ Check scanner is working correctly: node test/dependency_scanner.test.mjs node test/cve_integration.test.mjs node test/sast_engine.test.mjs +node test/dast_harness.test.mjs # Validate skill structure python ../../utils/validate_skill.py . @@ -364,6 +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 ``` ### Linting @@ -448,11 +456,11 @@ npx clawhub@latest install clawsec-suite ## Roadmap -### v0.1.0 (Current) +### v0.0.2 (Current) - [x] Dependency scanning (npm audit, pip-audit) - [x] CVE database integration (OSV, NVD, GitHub Advisory) - [x] SAST analysis (Semgrep, Bandit) -- [x] Basic DAST framework for skill hooks +- [x] Real OpenClaw hook execution harness for DAST - [x] Unified JSON reporting - [x] OpenClaw hook integration diff --git a/skills/clawsec-scanner/hooks/clawsec-scanner-hook/HOOK.md b/skills/clawsec-scanner/hooks/clawsec-scanner-hook/HOOK.md index 9c7a45e..9953cbc 100644 --- a/skills/clawsec-scanner/hooks/clawsec-scanner-hook/HOOK.md +++ b/skills/clawsec-scanner/hooks/clawsec-scanner-hook/HOOK.md @@ -20,7 +20,7 @@ The hook orchestrates four independent scanning engines: 1. **Dependency Scanning**: Executes `npm audit` and `pip-audit` to detect known vulnerabilities in JavaScript and Python dependencies 2. **SAST (Static Analysis)**: Runs Semgrep (JS/TS) and Bandit (Python) to detect security issues like hardcoded secrets, command injection, and path traversal 3. **CVE Database Lookup**: Queries OSV API (primary), NVD 2.0 (optional), and GitHub Advisory Database (optional) for vulnerability enrichment -4. **DAST (Dynamic Analysis)**: Tests skill hook security including input validation, timeout enforcement, and resource limits +4. **DAST (Dynamic Analysis)**: Executes real OpenClaw hook handlers in an isolated harness and tests malicious-input resilience, timeout behavior, output bounds, and event mutation safety ## Safety Contract diff --git a/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts b/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts index c1b4f90..1ec3d96 100644 --- a/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts +++ b/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts @@ -196,6 +196,11 @@ 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. + if (process.env.CLAWSEC_DAST_HARNESS === "1" || _context?.dastMode === true) { + return; + } + if (!shouldHandleEvent(event)) return; const installRoot = configuredPath( diff --git a/skills/clawsec-scanner/scripts/dast_hook_executor.mjs b/skills/clawsec-scanner/scripts/dast_hook_executor.mjs new file mode 100644 index 0000000..27d3372 --- /dev/null +++ b/skills/clawsec-scanner/scripts/dast_hook_executor.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node + +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 = { + handler: "", + exportName: "default", + eventB64: "", + contextB64: "", + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + + if (token === "--handler") { + parsed.handler = String(argv[i + 1] ?? "").trim(); + i += 1; + continue; + } + + if (token === "--export") { + parsed.exportName = String(argv[i + 1] ?? "default").trim() || "default"; + i += 1; + continue; + } + + if (token === "--event") { + parsed.eventB64 = String(argv[i + 1] ?? "").trim(); + i += 1; + continue; + } + + if (token === "--context") { + parsed.contextB64 = String(argv[i + 1] ?? "").trim(); + i += 1; + continue; + } + + throw new Error(`Unknown argument: ${token}`); + } + + if (!parsed.handler) { + 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); + return true; + } catch { + return false; + } +} + +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) { + const fullPath = path.resolve(handlerPath); + const exists = await fileExists(fullPath); + if (!exists) { + throw new Error(`Hook handler does not exist: ${fullPath}`); + } + + const ext = path.extname(fullPath).toLowerCase(); + + if (ext === ".ts") { + return importTypeScriptModule(fullPath); + } + + return import(`${pathToFileURL(fullPath).href}?v=${Date.now()}`); +} + +function resolveHandlerExport(mod, exportName) { + if (exportName && exportName !== "default") { + if (typeof mod?.[exportName] === "function") { + return mod[exportName]; + } + throw new Error(`Hook export '${exportName}' is not a function`); + } + + 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"); +} + +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 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, + }; +} + +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 payload = { + ok: true, + duration_ms: Date.now() - startedAt, + core_before: before, + core_after: after, + messages_count: messageSummary.count, + messages_char_count: messageSummary.charCount, + }; + + process.stdout.write(JSON.stringify(payload)); + } catch (error) { + const after = coreEventShape(event); + const messageSummary = summarizeMessages(event?.messages); + + const payload = { + ok: false, + 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), + }; + + process.stdout.write(JSON.stringify(payload)); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/skills/clawsec-scanner/scripts/dast_runner.mjs b/skills/clawsec-scanner/scripts/dast_runner.mjs index 96332e7..cd1d49f 100755 --- a/skills/clawsec-scanner/scripts/dast_runner.mjs +++ b/skills/clawsec-scanner/scripts/dast_runner.mjs @@ -3,422 +3,742 @@ /** * DAST (Dynamic Application Security Testing) Runner for ClawSec Scanner. * - * v1 Scope: Basic framework for testing skill hook security - * - Load and execute predefined security test cases - * - Test hook handlers with malicious inputs - * - Enforce timeout limits (30s default) - * - Validate input sanitization and resource limits - * - * Future: Full agent workflow DAST requires deeper platform integration - * - * Run: node skills/clawsec-scanner/scripts/dast_runner.mjs --target ./skills/ + * Scope: + * - Discover OpenClaw hooks from target directories + * - Execute real hook handlers in an isolated harness process + * - Validate malicious-input resilience, timeout behavior, output bounds, + * and event mutation safety */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + import { generateReport, formatReportJson, formatReportText } from "../lib/report.mjs"; import { getTimestamp } from "../lib/utils.mjs"; /** * @typedef {import('../lib/types.ts').Vulnerability} Vulnerability * @typedef {import('../lib/types.ts').ScanReport} ScanReport - * @typedef {import('../lib/types.ts').HookEvent} HookEvent */ +const DEFAULT_TIMEOUT_MS = 30000; +const MAX_OUTPUT_MESSAGES = 25; +const MAX_OUTPUT_CHARS = 20000; +const SKIP_DIR_NAMES = new Set([ + ".git", + ".github", + ".idea", + ".vscode", + "node_modules", + "dist", + "build", + "coverage", + ".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"); + /** - * @typedef {Object} TestCase - * @property {string} id - Test case identifier - * @property {string} name - Human-readable test name - * @property {string} description - Test description - * @property {'critical' | 'high' | 'medium' | 'low' | 'info'} severity - Severity if test fails - * @property {(context: TestContext) => Promise} execute - Test execution function + * @typedef {Object} HookDescriptor + * @property {string} name + * @property {string} hookDir + * @property {string} hookFile + * @property {string} handlerPath + * @property {string[]} events + * @property {string} exportName */ /** - * @typedef {Object} TestContext - * @property {string} targetPath - Path to target skill/hook - * @property {number} timeout - Timeout in milliseconds + * Parse CLI arguments. + * + * @param {string[]} argv + * @returns {{target: string, format: 'json' | 'text', timeout: number}} */ +function parseArgs(argv) { + const parsed = { + target: ".", + format: "json", + timeout: DEFAULT_TIMEOUT_MS, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + + if (token === "--target") { + parsed.target = String(argv[i + 1] ?? "").trim(); + i += 1; + continue; + } + + if (token === "--format") { + const value = String(argv[i + 1] ?? "json").trim(); + if (value !== "json" && value !== "text") { + throw new Error("Invalid --format value. Use 'json' or 'text'."); + } + parsed.format = value; + i += 1; + continue; + } + + if (token === "--timeout") { + const value = Number.parseInt(String(argv[i + 1] ?? ""), 10); + if (!Number.isFinite(value) || value <= 0) { + throw new Error("Invalid --timeout value. Must be a positive integer (milliseconds)."); + } + parsed.timeout = value; + i += 1; + continue; + } + + if (token === "--help" || token === "-h") { + printUsage(); + process.exit(0); + } + + throw new Error(`Unknown argument: ${token}`); + } + + if (!parsed.target) { + throw new Error("Missing required argument: --target"); + } + + return parsed; +} + +function printUsage() { + process.stderr.write( + [ + "Usage:", + " node scripts/dast_runner.mjs --target [--format json|text] [--timeout ms]", + "", + "Examples:", + " node scripts/dast_runner.mjs --target ./skills/", + " node scripts/dast_runner.mjs --target ./skills/ --format text", + " node scripts/dast_runner.mjs --target ./skills/ --timeout 60000", + "", + "Flags:", + " --target Target skill/hook directory to test (required)", + " --format Output format: json or text (default: json)", + ` --timeout Per-hook invocation timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`, + "", + ].join("\n"), + ); +} /** - * @typedef {Object} TestResult - * @property {boolean} passed - Whether test passed - * @property {string} [message] - Result message - * @property {unknown} [details] - Additional details + * @param {string} filePath + * @returns {Promise} */ +async function fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} -const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds +/** + * @param {string} markdown + * @returns {string} + */ +function extractFrontmatter(markdown) { + const match = markdown.match(/^---\n([\s\S]*?)\n---/); + return match ? match[1] : ""; +} /** - * Security test cases for hook handlers. - * @type {TestCase[]} + * @param {string} frontmatter + * @returns {string[]} */ -const TEST_CASES = [ - { - id: "DAST-001", - name: "Hook Handler Malicious Input Test", - description: "Tests hook handler behavior with malicious message inputs (XSS, command injection patterns)", - severity: "high", - execute: async (context) => { - const maliciousInputs = [ - // XSS patterns - "", - "'; DROP TABLE users; --", - // Command injection patterns - "; rm -rf /", - "$(whoami)", - "`cat /etc/passwd`", - // Path traversal - "../../../etc/passwd", - "..\\..\\..\\windows\\system32\\config\\sam", - // Null bytes - "test\0malicious", - // Unicode exploits - "\u202e\u202d", - // Large payload (potential DoS) - "A".repeat(1000000), - ]; - - const vulnerabilities = []; - - for (const input of maliciousInputs) { - try { - // Test: Create mock hook event with malicious content - const mockEvent = { - type: "test", - action: "security-test", - messages: [ - { - role: "user", - content: input, - }, - ], - }; - - // In a real implementation, this would invoke the actual hook handler - // For v1, we simulate by checking if the input would cause issues - const result = await testHookHandlerSafety(mockEvent, context.timeout); - - if (!result.safe) { - vulnerabilities.push({ - pattern: input.substring(0, 50), - reason: result.reason, - }); - } - } catch (error) { - if (error instanceof Error) { - vulnerabilities.push({ - pattern: input.substring(0, 50), - reason: `Exception thrown: ${error.message}`, - }); - } - } - } +function parseEvents(frontmatter) { + const defaultEvents = ["command:new"]; + if (!frontmatter) return defaultEvents; - return { - passed: vulnerabilities.length === 0, - message: - vulnerabilities.length === 0 - ? "Hook handler safely processes malicious inputs" - : `Hook handler vulnerable to ${vulnerabilities.length} input patterns`, - details: { vulnerabilities }, - }; - }, - }, - { - id: "DAST-002", - name: "Hook Handler Timeout Enforcement", - description: "Tests whether hook handlers respect timeout limits and prevent infinite loops", - severity: "medium", - execute: async (_context) => { - const startTime = Date.now(); - const testTimeout = 5000; // 5 second test timeout + const jsonStyle = frontmatter.match(/"events"\s*:\s*\[([^\]]*)\]/m); + const yamlStyle = frontmatter.match(/events\s*:\s*\[([^\]]*)\]/m); + const raw = jsonStyle?.[1] ?? yamlStyle?.[1]; - try { - // Simulate a long-running operation - const result = await Promise.race([ - simulateLongRunningHook(), - new Promise((resolve) => - setTimeout(() => resolve({ timedOut: true }), testTimeout), - ), - ]); - - const elapsed = Date.now() - startTime; - - if (result && typeof result === "object" && "timedOut" in result && result.timedOut) { - return { - passed: true, - message: `Timeout correctly enforced (${elapsed}ms < ${testTimeout}ms)`, - }; - } + if (!raw) return defaultEvents; - return { - passed: elapsed < testTimeout, - message: - elapsed < testTimeout - ? `Operation completed within timeout (${elapsed}ms)` - : `Operation exceeded timeout (${elapsed}ms > ${testTimeout}ms)`, - }; - } catch (error) { - if (error instanceof Error) { - return { - passed: false, - message: `Timeout test failed: ${error.message}`, - }; + const events = []; + const quotedRegex = /"([^"]+)"|'([^']+)'/g; + + let quotedMatch = quotedRegex.exec(raw); + while (quotedMatch) { + const value = quotedMatch[1] || quotedMatch[2]; + if (value && value.includes(":")) { + events.push(value.trim()); + } + quotedMatch = quotedRegex.exec(raw); + } + + if (events.length === 0) { + const fallback = raw + .split(",") + .map((part) => part.trim()) + .map((part) => part.replace(/^['"]|['"]$/g, "")) + .filter((part) => part.includes(":")); + events.push(...fallback); + } + + return events.length > 0 ? Array.from(new Set(events)) : defaultEvents; +} + +/** + * @param {string} frontmatter + * @param {string} fallback + * @returns {string} + */ +function parseHookName(frontmatter, fallback) { + if (!frontmatter) return fallback; + + const match = frontmatter.match(/^name\s*:\s*(.+)$/m); + if (!match) return fallback; + + return match[1].trim().replace(/^['"]|['"]$/g, "") || fallback; +} + +/** + * @param {string} frontmatter + * @returns {string} + */ +function parseExportName(frontmatter) { + if (!frontmatter) return "default"; + + const jsonStyle = frontmatter.match(/"export"\s*:\s*"([^"]+)"/m); + if (jsonStyle?.[1]) return jsonStyle[1].trim(); + + const yamlStyle = frontmatter.match(/^export\s*:\s*(.+)$/m); + if (yamlStyle?.[1]) { + const value = yamlStyle[1].trim().replace(/^['"]|['"]$/g, ""); + return value || "default"; + } + + return "default"; +} + +/** + * @param {string} hookDir + * @returns {Promise} + */ +async function resolveHandlerPath(hookDir) { + const candidates = [ + "handler.mjs", + "handler.js", + "handler.cjs", + "handler.ts", + "index.mjs", + "index.js", + "index.cjs", + "index.ts", + ]; + + for (const candidate of candidates) { + const fullPath = path.join(hookDir, candidate); + if (await fileExists(fullPath)) { + return fullPath; + } + } + + return null; +} + +/** + * @param {string} targetPath + * @returns {Promise} + */ +export async function discoverHooks(targetPath) { + const hooks = []; + const absoluteTarget = path.resolve(targetPath); + + /** + * @param {string} dir + * @returns {Promise} + */ + async function walk(dir) { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (SKIP_DIR_NAMES.has(entry.name)) { + continue; } - return { - passed: false, - message: "Timeout test failed with unknown error", - }; + + await walk(fullPath); + continue; } - }, - }, - { - id: "DAST-003", - name: "Hook Handler Resource Limits", - description: "Tests whether hook handlers respect memory and CPU resource limits", - severity: "medium", - execute: async (context) => { - const initialMemory = process.memoryUsage().heapUsed; - const maxMemoryIncreaseMB = 50; // Alert if memory increases by more than 50MB - try { - // Simulate resource-intensive operation - await simulateResourceIntensiveHook(context.timeout); - - const finalMemory = process.memoryUsage().heapUsed; - const memoryIncreaseMB = (finalMemory - initialMemory) / 1024 / 1024; - - return { - passed: memoryIncreaseMB < maxMemoryIncreaseMB, - message: - memoryIncreaseMB < maxMemoryIncreaseMB - ? `Memory usage within limits (${memoryIncreaseMB.toFixed(2)}MB increase)` - : `Memory usage exceeded limits (${memoryIncreaseMB.toFixed(2)}MB increase)`, - details: { - initialMemoryMB: (initialMemory / 1024 / 1024).toFixed(2), - finalMemoryMB: (finalMemory / 1024 / 1024).toFixed(2), - increaseMB: memoryIncreaseMB.toFixed(2), - }, - }; - } catch (error) { - if (error instanceof Error) { - return { - passed: false, - message: `Resource limit test failed: ${error.message}`, - }; - } - return { - passed: false, - message: "Resource limit test failed with unknown error", - }; + if (!entry.isFile() || entry.name !== "HOOK.md") { + continue; } - }, - }, - { - id: "DAST-004", - name: "Hook Handler Event Mutation Safety", - description: "Tests whether hook handlers properly mutate event.messages without side effects", - severity: "low", - execute: async (_context) => { - const originalEvent = { - type: "test", - action: "mutation-test", - messages: [{ role: "user", content: "test message" }], - }; - // Clone for comparison - const originalMessagesCount = originalEvent.messages.length; - const originalMessageContent = originalEvent.messages[0].content; + const hookDir = path.dirname(fullPath); + const hookMd = await fs.readFile(fullPath, "utf8"); + const frontmatter = extractFrontmatter(hookMd); + const handlerPath = await resolveHandlerPath(hookDir); - try { - // Simulate hook handler mutation - const mockHandler = async (event) => { - // Proper hook pattern: mutate event.messages - event.messages.push({ - role: "system", - content: "Hook handler response", - }); - // No return value (correct pattern) - }; - - await mockHandler(originalEvent); - - const messagesIncreased = originalEvent.messages.length > originalMessagesCount; - const originalMessageIntact = - originalEvent.messages[0].content === originalMessageContent; - - return { - passed: messagesIncreased && originalMessageIntact, - message: messagesIncreased - ? "Hook correctly mutates event.messages" - : "Hook does not mutate event.messages", - details: { - originalCount: originalMessagesCount, - finalCount: originalEvent.messages.length, - originalIntact: originalMessageIntact, - }, - }; - } catch (error) { - if (error instanceof Error) { - return { - passed: false, - message: `Event mutation test failed: ${error.message}`, - }; - } - return { - passed: false, - message: "Event mutation test failed with unknown error", - }; + if (!handlerPath) { + continue; } + + hooks.push({ + name: parseHookName(frontmatter, path.basename(hookDir)), + hookDir, + hookFile: fullPath, + handlerPath, + events: parseEvents(frontmatter), + exportName: parseExportName(frontmatter), + }); + } + } + + await walk(absoluteTarget); + + 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: [], }, - }, -]; + }; +} /** - * Test hook handler safety with malicious input. - * In v1, this is a simple simulation. Future versions will invoke actual handlers. - * - * @param {HookEvent} event - Mock hook event - * @param {number} timeout - Timeout in milliseconds - * @returns {Promise<{safe: boolean, reason?: string}>} + * @typedef {Object} HarnessInvocationResult + * @property {boolean} timedOut + * @property {number} exitCode + * @property {string} stderr + * @property {Record | null} parsed + * @property {string | null} parseError + */ + +/** + * @param {HookDescriptor} hook + * @param {Record} event + * @param {Record} context + * @param {number} timeoutMs + * @returns {Promise} */ -async function testHookHandlerSafety(event, timeout) { +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"); + + 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", + }, + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { - resolve({ safe: true, reason: "Handler completed within timeout" }); - }, timeout); + timedOut = true; + proc.kill("SIGKILL"); + }, timeoutMs); - try { - // v1: Basic safety checks (pattern matching) - const content = event.messages?.[0]?.content ?? ""; + proc.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); - // Check for unsafe patterns - if (content.includes("")) { - clearTimeout(timer); - resolve({ safe: false, reason: "Detected XSS pattern" }); - return; - } + proc.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); - if ( - content.includes("rm -rf") || - content.includes("$(") || - content.includes("`") - ) { - clearTimeout(timer); - resolve({ safe: false, reason: "Detected command injection pattern" }); - return; - } + proc.on("close", (code) => { + clearTimeout(timer); - if (content.includes("../") || content.includes("..\\")) { - clearTimeout(timer); - resolve({ safe: false, reason: "Detected path traversal pattern" }); + const raw = stdout.trim(); + if (!raw) { + resolve({ + timedOut, + exitCode: code ?? 1, + stderr, + parsed: null, + parseError: raw ? null : "Harness produced no JSON output", + }); return; } - if (content.includes("\0")) { - clearTimeout(timer); - resolve({ safe: false, reason: "Detected null byte injection" }); - return; + try { + const parsed = JSON.parse(raw); + resolve({ + timedOut, + exitCode: code ?? 1, + stderr, + parsed, + parseError: null, + }); + } catch (error) { + resolve({ + timedOut, + exitCode: code ?? 1, + stderr, + parsed: null, + parseError: error instanceof Error ? error.message : String(error), + }); } + }); + }); +} - // Check for excessive payload size - if (content.length > 100000) { - clearTimeout(timer); - resolve({ safe: false, reason: "Excessive payload size (potential DoS)" }); - return; - } +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isObject(value) { + return typeof value === "object" && value !== null; +} - clearTimeout(timer); - resolve({ safe: true }); - } catch (error) { - clearTimeout(timer); - if (error instanceof Error) { - resolve({ safe: false, reason: `Exception: ${error.message}` }); - } else { - resolve({ safe: false, reason: "Unknown exception" }); - } - } - }); +/** + * @param {unknown} parsed + * @returns {{ok: boolean, error: string, messagesCount: number, messagesCharCount: number, coreAfter: Record}} + */ +function normalizeHarnessPayload(parsed) { + if (!isObject(parsed)) { + return { + ok: false, + error: "Harness output is not an object", + messagesCount: 0, + messagesCharCount: 0, + coreAfter: {}, + }; + } + + 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 : {}; + + return { + ok, + error, + messagesCount, + messagesCharCount, + coreAfter, + }; } /** - * Simulate a long-running hook operation. - * - * @returns {Promise<{completed: boolean}>} + * @param {string} input + * @returns {string} */ -async function simulateLongRunningHook() { - return new Promise((resolve) => { - // Simulate operation that would take too long - setTimeout(() => { - resolve({ completed: true }); - }, 60000); // 60 seconds - should be timed out before this - }); +function slug(input) { + return String(input) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); } /** - * Simulate a resource-intensive hook operation. - * - * @param {number} _timeout - Timeout in milliseconds - * @returns {Promise} + * @param {string} reason + * @returns {boolean} */ -async function simulateResourceIntensiveHook(_timeout) { - return new Promise((resolve) => { - setTimeout(() => { - // Simulate some memory usage (small allocation for testing) - const tempData = new Array(1000).fill("test data"); - tempData.length = 0; // Clean up - resolve(); - }, 100); +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 + * @param {'critical' | 'high' | 'medium' | 'low' | 'info'} severity + * @param {HookDescriptor} hook + * @param {string} eventKey + * @param {string} title + * @param {string} description + */ +function pushHookVulnerability(bucket, id, severity, hook, eventKey, title, description) { + bucket.push({ + id, + source: "dast", + severity, + package: hook.name, + version: `${eventKey}:${path.basename(hook.handlerPath)}`, + fixed_version: "", + title, + description, + references: [hook.hookFile], + discovered_at: getTimestamp(), }); } /** - * Execute all DAST test cases. - * - * @param {string} targetPath - Path to target skill/hook - * @param {number} timeout - Timeout in milliseconds + * @param {HookDescriptor} hook + * @param {string} targetPath + * @param {number} timeoutMs * @returns {Promise} */ -async function runDastTests(targetPath, timeout) { - const vulnerabilities = []; +async function evaluateHook(hook, targetPath, timeoutMs) { + const findings = []; + const invocationTimeoutMs = Math.max(1000, timeoutMs); + + 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) { + 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; + } - const context = { - targetPath, - timeout, - }; + if (safeResult.parseError) { + pushHookVulnerability( + findings, + `DAST-HARNESS-${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)"}`, + ); + continue; + } - for (const testCase of TEST_CASES) { - try { - const result = await testCase.execute(context); - - if (!result.passed) { - vulnerabilities.push({ - id: testCase.id, - source: "dast", - severity: testCase.severity, - package: "N/A", - version: "N/A", - title: testCase.name, - description: `${testCase.description}\n\nResult: ${result.message}`, - references: [], - discovered_at: getTimestamp(), - }); + 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}`, + ); } - } catch (error) { - // Test execution failure is itself a vulnerability - vulnerabilities.push({ - id: testCase.id, - source: "dast", - severity: "high", - package: "N/A", - version: "N/A", - title: `${testCase.name} (Test Failed)`, - description: `Test execution failed: ${error instanceof Error ? error.message : String(error)}`, - references: [], - discovered_at: getTimestamp(), - }); + continue; + } + + const mutationObserved = + normalizedSafe.coreAfter.type !== safeEvent.type || + normalizedSafe.coreAfter.action !== safeEvent.action || + normalizedSafe.coreAfter.sessionKey !== safeEvent.sessionKey; + + if (mutationObserved) { + 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}`)}`, + "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.`, + ); + } + + 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) { + pushHookVulnerability( + findings, + `DAST-MALICIOUS-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`, + "high", + 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` : ""}).`, + ); + } + + 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)` : ""}`, + ); } } + return findings; +} + +/** + * Execute DAST hook tests. + * + * @param {string} targetPath + * @param {number} timeout + * @returns {Promise} + */ +export async function runDastTests(targetPath, timeout) { + const hooks = await discoverHooks(targetPath); + if (hooks.length === 0) { + process.stderr.write(`[dast] No OpenClaw hooks discovered under ${targetPath}; skipping DAST harness execution\n`); + return []; + } + + const vulnerabilities = []; + + for (const hook of hooks) { + const hookFindings = await evaluateHook(hook, targetPath, timeout); + vulnerabilities.push(...hookFindings); + } + return vulnerabilities; } @@ -426,73 +746,40 @@ async function runDastTests(targetPath, timeout) { * CLI entry point. */ async function main() { - const args = process.argv.slice(2); - - let targetPath = "."; - let format = "json"; - let timeout = DEFAULT_TIMEOUT_MS; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--target" && args[i + 1]) { - targetPath = args[i + 1]; - i++; - } else if (args[i] === "--format" && args[i + 1]) { - format = args[i + 1]; - i++; - } else if (args[i] === "--timeout" && args[i + 1]) { - timeout = parseInt(args[i + 1], 10); - if (isNaN(timeout) || timeout <= 0) { - timeout = DEFAULT_TIMEOUT_MS; - } - i++; - } else if (args[i] === "--help") { - console.log(` -Usage: dast_runner.mjs [options] - -Options: - --target Target skill/hook directory to test (default: .) - --format Output format: json or text (default: json) - --timeout Test timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS}) - --help Show this help message - -Examples: - node dast_runner.mjs --target ./skills/my-skill - node dast_runner.mjs --target ./skills/ --format text - node dast_runner.mjs --target ./skills/ --timeout 60000 -`); - process.exit(0); + try { + const args = parseArgs(process.argv.slice(2)); + + const targetExists = await fileExists(args.target); + if (!targetExists) { + throw new Error(`Target path does not exist: ${args.target}`); } - } - try { - const vulnerabilities = await runDastTests(targetPath, timeout); - const report = generateReport(vulnerabilities, targetPath); + const vulnerabilities = await runDastTests(args.target, args.timeout); + const report = generateReport(vulnerabilities, args.target); - if (format === "text") { - console.log(formatReportText(report)); + if (args.format === "text") { + process.stdout.write(formatReportText(report)); + process.stdout.write("\n"); } else { - console.log(formatReportJson(report)); + process.stdout.write(formatReportJson(report)); + process.stdout.write("\n"); } - // Exit with non-zero if critical or high severity vulnerabilities found - const hasCriticalOrHigh = - report.summary.critical > 0 || report.summary.high > 0; + const hasCriticalOrHigh = report.summary.critical > 0 || report.summary.high > 0; process.exit(hasCriticalOrHigh ? 1 : 0); } catch (error) { - console.error("DAST runner failed:"); + process.stderr.write("DAST runner failed:\n"); if (error instanceof Error) { - console.error(error.message); + process.stderr.write(`${error.message}\n`); } else { - console.error(String(error)); + process.stderr.write(`${String(error)}\n`); } process.exit(1); } } -// Export for testing -export { runDastTests, testHookHandlerSafety, TEST_CASES }; +export { MALICIOUS_PAYLOADS }; -// Run if invoked directly if (import.meta.url === `file://${process.argv[1]}`) { main(); } diff --git a/skills/clawsec-scanner/scripts/setup_scanner_hook.mjs b/skills/clawsec-scanner/scripts/setup_scanner_hook.mjs index f04e063..c1d2f91 100755 --- a/skills/clawsec-scanner/scripts/setup_scanner_hook.mjs +++ b/skills/clawsec-scanner/scripts/setup_scanner_hook.mjs @@ -73,6 +73,7 @@ function assertSourceHookExists() { "scripts/scan_dependencies.mjs", "scripts/sast_analyzer.mjs", "scripts/dast_runner.mjs", + "scripts/dast_hook_executor.mjs", "scripts/query_cve_databases.mjs", ]; for (const file of requiredScripts) { diff --git a/skills/clawsec-scanner/skill.json b/skills/clawsec-scanner/skill.json index a75107a..bb58a73 100644 --- a/skills/clawsec-scanner/skill.json +++ b/skills/clawsec-scanner/skill.json @@ -1,7 +1,7 @@ { "name": "clawsec-scanner", - "version": "0.0.1", - "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 basic DAST security testing for skill hooks.", + "version": "0.0.2", + "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.", "author": "prompt-security", "license": "AGPL-3.0-or-later", "homepage": "https://clawsec.prompt.security/", @@ -57,7 +57,12 @@ { "path": "scripts/dast_runner.mjs", "required": true, - "description": "Dynamic analysis framework for skill hook security testing" + "description": "Dynamic analysis harness executing OpenClaw hook handlers with malicious-input and timeout checks" + }, + { + "path": "scripts/dast_hook_executor.mjs", + "required": true, + "description": "Isolated hook execution helper used by DAST for real OpenClaw harness testing" }, { "path": "scripts/setup_scanner_hook.mjs", @@ -103,6 +108,11 @@ "path": "test/sast_engine.test.mjs", "required": false, "description": "Unit tests for SAST analysis (Semgrep, Bandit)" + }, + { + "path": "test/dast_harness.test.mjs", + "required": false, + "description": "DAST harness tests for real hook execution and malicious-input failure detection" } ] }, diff --git a/skills/clawsec-scanner/test/dast_harness.test.mjs b/skills/clawsec-scanner/test/dast_harness.test.mjs new file mode 100644 index 0000000..5a87d6b --- /dev/null +++ b/skills/clawsec-scanner/test/dast_harness.test.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + pass, + fail, + report, + exitWithResults, + createTempDir, +} from "./lib/test_harness.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SKILL_ROOT = path.resolve(__dirname, ".."); +const DAST_SCRIPT = path.join(SKILL_ROOT, "scripts", "dast_runner.mjs"); + +/** + * @param {string} targetPath + * @param {number} timeoutMs + * @param {Record} envOverrides + * @returns {Promise<{code: number, stdout: string, stderr: string, report: any}>} + */ +async function runDast(targetPath, timeoutMs = 3000, envOverrides = {}) { + return new Promise((resolve, reject) => { + const proc = spawn( + "node", + [DAST_SCRIPT, "--target", targetPath, "--format", "json", "--timeout", String(timeoutMs)], + { + cwd: SKILL_ROOT, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + ...envOverrides, + }, + }, + ); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + + proc.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + proc.on("error", reject); + + proc.on("close", (code) => { + try { + const parsed = JSON.parse(stdout.trim()); + resolve({ + code: code ?? 1, + stdout, + stderr, + report: parsed, + }); + } catch (error) { + reject(new Error(`Failed to parse DAST JSON output: ${String(error)}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)); + } + }); + }); +} + +/** + * @param {string} hookDir + * @param {string} eventsLiteral + * @param {string} handlerSource + * @param {string} [handlerFile] + * @returns {Promise} + */ +async function writeHookFixture(hookDir, eventsLiteral, handlerSource, handlerFile = "handler.js") { + await fs.mkdir(hookDir, { recursive: true }); + + const hookMd = `--- +name: ${path.basename(hookDir)} +description: fixture hook +metadata: { "openclaw": { "events": [${eventsLiteral}] } } +--- + +# Fixture Hook +`; + + await fs.writeFile(path.join(hookDir, "HOOK.md"), hookMd, "utf8"); + 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"; + const tmp = await createTempDir(); + + try { + const targetPath = path.join(tmp.path, "skill"); + const hookDir = path.join(targetPath, "hooks", "safe-hook"); + const markerFile = path.join(hookDir, "executed.marker"); + + await writeHookFixture( + hookDir, + '"command:new"', + `import fs from "node:fs/promises"; +import path from "node:path"; + +const handler = async (event, context) => { + const marker = path.join(path.dirname(new URL(import.meta.url).pathname), "executed.marker"); + await fs.writeFile(marker, String(context?.event || "unknown"), "utf8"); + + if (!Array.isArray(event.messages)) { + event.messages = []; + } + + event.messages.push("hook executed"); +}; + +export default handler; +`, + ); + + const result = await runDast(targetPath, 2500); + const markerExists = await fs + .access(markerFile) + .then(() => true) + .catch(() => false); + + const cleanSummary = + 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; + + if (result.code === 0 && markerExists && cleanSummary) { + 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}`, + ); + } + } catch (error) { + fail(testName, error); + } finally { + await tmp.cleanup(); + } +} + +async function testMaliciousCrashProducesHighFinding() { + const testName = "DAST harness: malicious input crash is reported as high"; + const tmp = await createTempDir(); + + try { + const targetPath = path.join(tmp.path, "skill"); + const hookDir = path.join(targetPath, "hooks", "crashy-hook"); + + await writeHookFixture( + hookDir, + '"message:preprocessed"', + `const handler = async (event) => { + const payload = String(event?.context?.content || ""); + if (payload.includes("