Files
clawsec/skills/clawsec-scanner/scripts/dast_runner.mjs
davida-ps 3cef7aa46b fix(security): harden high scan findings (#258)
* fix(security): harden high scan findings

* fix(security): tighten review hardening

* fix(nanoclaw): preserve prerelease advisory matching
2026-06-07 13:00:56 +03:00

610 lines
15 KiB
JavaScript
Executable File

#!/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 <path> [--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<boolean>}
*/
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<string | null>}
*/
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<HookDescriptor[]>}
*/
export async function discoverHooks(targetPath) {
const hooks = [];
const absoluteTarget = path.resolve(targetPath);
/**
* @param {string} dir
* @returns {Promise<void>}
*/
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<string, unknown> | null} parsed
* @property {string | null} parseError
*/
/**
* @param {HookDescriptor} hook
* @param {number} timeoutMs
* @returns {Promise<HarnessInvocationResult>}
*/
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<string, unknown>}
*/
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<Vulnerability[]>}
*/
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<Vulnerability[]>}
*/
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();
}