#!/usr/bin/env node /** * DAST (Dynamic Application Security Testing) Runner for ClawSec Scanner. * * 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 */ const DEFAULT_TIMEOUT_MS = 30000; const SKIP_DIR_NAMES = new Set([ ".git", ".github", ".idea", ".vscode", "node_modules", "dist", "build", "coverage", ".openclaw", ]); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const HOOK_EXECUTOR_PATH = path.join(__dirname, "dast_hook_executor.mjs"); /** * @typedef {Object} HookDescriptor * @property {string} name * @property {string} hookDir * @property {string} hookFile * @property {string} handlerPath * @property {string[]} events * @property {string} exportName */ /** * 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"), ); } /** * @param {string} filePath * @returns {Promise} */ async function fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } /** * @param {string} markdown * @returns {string} */ function extractFrontmatter(markdown) { const match = markdown.match(/^---\n([\s\S]*?)\n---/); return match ? match[1] : ""; } /** * @param {string} frontmatter * @returns {string[]} */ function parseEvents(frontmatter) { const defaultEvents = ["command:new"]; if (!frontmatter) return defaultEvents; const jsonStyle = frontmatter.match(/"events"\s*:\s*\[([^\]]*)\]/m); const yamlStyle = frontmatter.match(/events\s*:\s*\[([^\]]*)\]/m); const raw = jsonStyle?.[1] ?? yamlStyle?.[1]; if (!raw) return defaultEvents; 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; } await walk(fullPath); continue; } if (!entry.isFile() || entry.name !== "HOOK.md") { continue; } const hookDir = path.dirname(fullPath); const hookMd = await fs.readFile(fullPath, "utf8"); const frontmatter = extractFrontmatter(hookMd); const handlerPath = await resolveHandlerPath(hookDir); 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; } /** * @typedef {Object} HarnessInvocationResult * @property {boolean} timedOut * @property {number} exitCode * @property {string} stderr * @property {Record | null} parsed * @property {string | null} parseError */ /** * @param {HookDescriptor} hook * @param {number} timeoutMs * @returns {Promise} */ async function inspectHookHandler(hook, timeoutMs) { const args = [ HOOK_EXECUTOR_PATH, "--handler", hook.handlerPath, "--export", hook.exportName || "default", ]; return new Promise((resolve) => { const proc = spawn("node", args, { stdio: ["ignore", "pipe", "pipe"], env: { PATH: process.env.PATH || "", CLAWSEC_DAST_STATIC_INSPECTION: "1", }, }); let stdout = ""; let stderr = ""; let timedOut = false; const timer = setTimeout(() => { timedOut = true; proc.kill("SIGKILL"); }, timeoutMs); proc.stdout.on("data", (chunk) => { stdout += String(chunk); }); proc.stderr.on("data", (chunk) => { stderr += String(chunk); }); proc.on("close", (code) => { clearTimeout(timer); const raw = stdout.trim(); if (!raw) { resolve({ timedOut, exitCode: code ?? 1, stderr, parsed: null, parseError: raw ? null : "Harness produced no JSON output", }); 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), }); } }); }); } /** * @param {unknown} value * @returns {value is Record} */ function isObject(value) { return typeof value === "object" && value !== null; } /** * @param {unknown} parsed * @returns {{ok: boolean, error: string, staticOnly: boolean, riskSignals: string[], handlerExportDeclared: boolean}} */ function normalizeStaticPayload(parsed) { if (!isObject(parsed)) { return { ok: false, error: "Harness output is not an object", staticOnly: false, riskSignals: [], handlerExportDeclared: false, }; } const ok = parsed.ok === true; const error = typeof parsed.error === "string" ? parsed.error : ""; 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, staticOnly, riskSignals, handlerExportDeclared, }; } /** * @param {string} input * @returns {string} */ function slug(input) { return String(input) .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); } /** * @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(), }); } /** * @param {HookDescriptor} hook * @param {string} _targetPath * @param {number} timeoutMs * @returns {Promise} */ 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) { if (inspection.timedOut) { pushHookVulnerability( findings, `DAST-STATIC-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`, "medium", hook, eventKey, "Hook static inspection timed out", `Static hook inspection exceeded ${invocationTimeoutMs}ms for event '${eventKey}'. Target code was not executed.`, ); continue; } if (inspection.parseError) { pushHookVulnerability( findings, `DAST-STATIC-HARNESS-${slug(`${hook.name}-${eventKey}`)}`, "medium", hook, eventKey, "Hook static inspection output invalid", `Could not parse static inspection output for event '${eventKey}': ${inspection.parseError}. stderr: ${inspection.stderr || "(empty)"}`, ); continue; } const normalized = normalizeStaticPayload(inspection.parsed); if (!normalized.ok || !normalized.staticOnly) { const reason = normalized.error || inspection.stderr || "unknown static inspection error"; pushHookVulnerability( findings, `DAST-STATIC-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`, "info", hook, eventKey, "Hook not executed during DAST static inspection", `DAST did not execute hook code for event '${eventKey}'. Static inspection failed with: ${reason}`, ); continue; } 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; } /** * 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; } /** * CLI entry point. */ async function main() { 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}`); } const vulnerabilities = await runDastTests(args.target, args.timeout); const report = generateReport(vulnerabilities, args.target); if (args.format === "text") { process.stdout.write(formatReportText(report)); process.stdout.write("\n"); } else { process.stdout.write(formatReportJson(report)); process.stdout.write("\n"); } const hasCriticalOrHigh = report.summary.critical > 0 || report.summary.high > 0; process.exit(hasCriticalOrHigh ? 1 : 0); } catch (error) { process.stderr.write("DAST runner failed:\n"); if (error instanceof Error) { process.stderr.write(`${error.message}\n`); } else { process.stderr.write(`${String(error)}\n`); } process.exit(1); } } if (import.meta.url === `file://${process.argv[1]}`) { main(); }