Files
clawsec/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts
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

314 lines
9.3 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execCommand, safeJsonParse } from "../../lib/utils.mjs";
import { formatReportText } from "../../lib/report.mjs";
import type { HookEvent, HookContext, ScanReport } from "../../lib/types.ts";
const DEFAULT_SCAN_INTERVAL_SECONDS = 86400; // 24 hours
const DEFAULT_SCANNER_TIMEOUT = 300; // 5 minutes
const DEFAULT_MIN_SEVERITY = "medium";
let unsignedModeWarningShown = false;
interface ScannerState {
last_hook_scan: string | null;
last_full_scan: string | null;
known_vulnerabilities: string[];
}
function parsePositiveInteger(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
function toEventName(event: HookEvent): string {
const eventType = String(event.type ?? "").trim();
const action = String(event.action ?? "").trim();
if (!eventType || !action) return "";
return `${eventType}:${action}`;
}
function shouldHandleEvent(event: HookEvent): boolean {
const eventName = toEventName(event);
return eventName === "agent:bootstrap" || eventName === "command:new";
}
function epochMs(isoTimestamp: string | null): number {
if (!isoTimestamp) return 0;
const parsed = Date.parse(isoTimestamp);
return Number.isNaN(parsed) ? 0 : parsed;
}
function scannedRecently(lastScan: string | null, minIntervalSeconds: number): boolean {
const sinceMs = Date.now() - epochMs(lastScan);
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
}
function configuredPath(
explicit: string | undefined,
fallback: string,
label: string,
): string {
if (!explicit) return fallback;
const resolved = path.resolve(explicit);
try {
// Basic validation - check if path is a string
if (typeof resolved === "string" && resolved.length > 0) {
return resolved;
}
} catch (error) {
console.warn(
`[clawsec-scanner-hook] invalid ${label} path "${explicit}", using default "${fallback}": ${String(error)}`,
);
}
return fallback;
}
async function loadState(stateFile: string): Promise<ScannerState> {
try {
const content = await fs.readFile(stateFile, "utf8");
const parsed = safeJsonParse(content, { fallback: {}, label: "scanner state" });
const parsedState =
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
return {
last_hook_scan:
typeof parsedState.last_hook_scan === "string" ? parsedState.last_hook_scan : null,
last_full_scan:
typeof parsedState.last_full_scan === "string" ? parsedState.last_full_scan : null,
known_vulnerabilities: Array.isArray(parsedState.known_vulnerabilities)
? parsedState.known_vulnerabilities.filter((v): v is string => typeof v === "string")
: [],
};
} catch {
// State file doesn't exist yet - return empty state
return {
last_hook_scan: null,
last_full_scan: null,
known_vulnerabilities: [],
};
}
}
async function persistState(stateFile: string, state: ScannerState): Promise<void> {
try {
const dir = path.dirname(stateFile);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf8");
} catch (error) {
console.warn(`[clawsec-scanner-hook] failed to persist state: ${String(error)}`);
}
}
async function runScanner(
targetPath: string,
options: {
skipDeps: boolean;
skipSast: boolean;
skipDast: boolean;
skipCve: boolean;
timeout: number;
},
): Promise<ScanReport | null> {
try {
const scriptPath = path.join(path.dirname(new URL(import.meta.url).pathname), "../../scripts/runner.sh");
const args = ["--target", targetPath, "--format", "json"];
if (options.skipDeps) args.push("--skip-deps");
if (options.skipSast) args.push("--skip-sast");
if (options.skipDast) args.push("--skip-dast");
if (options.skipCve) args.push("--skip-cve");
const { stdout, stderr } = await execCommand("bash", [scriptPath, ...args]);
if (stderr && !stdout) {
console.warn(`[clawsec-scanner-hook] scanner warning: ${stderr}`);
}
const report = safeJsonParse(stdout, { fallback: null, label: "scanner report" });
if (!report || typeof report !== "object") {
console.warn("[clawsec-scanner-hook] scanner produced invalid report");
return null;
}
return report as ScanReport;
} catch (error) {
console.warn(`[clawsec-scanner-hook] scanner execution failed: ${String(error)}`);
return null;
}
}
function shouldReportSeverity(severity: string, minSeverity: string): boolean {
const severityOrder = ["info", "low", "medium", "high", "critical"];
const minIndex = severityOrder.indexOf(minSeverity.toLowerCase());
const vulnIndex = severityOrder.indexOf(severity.toLowerCase());
if (minIndex === -1 || vulnIndex === -1) return true;
return vulnIndex >= minIndex;
}
function deduplicateVulnerabilities(
report: ScanReport,
knownVulnIds: string[],
): ScanReport {
const knownSet = new Set(knownVulnIds);
const newVulnerabilities = report.vulnerabilities.filter(
(vuln) => !knownSet.has(vuln.id),
);
// Recalculate summary for new vulnerabilities
const summary = {
critical: 0,
high: 0,
medium: 0,
low: 0,
info: 0,
};
for (const vuln of newVulnerabilities) {
const severity = vuln.severity;
if (severity in summary) {
summary[severity]++;
}
}
return {
...report,
vulnerabilities: newVulnerabilities,
summary,
};
}
function buildAlertMessage(report: ScanReport, format: string): string {
if (format === "json") {
return JSON.stringify(report, null, 2);
}
return formatReportText(report);
}
const handler = async (event: HookEvent, _context: HookContext): Promise<void> => {
// Preserve the legacy DAST guard so older scanner harnesses cannot recurse.
if (process.env.CLAWSEC_DAST_HARNESS === "1" || _context?.dastMode === true) {
return;
}
if (!shouldHandleEvent(event)) return;
const installRoot = configuredPath(
process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT,
path.join(os.homedir(), ".openclaw", "skills"),
"CLAWSEC_INSTALL_ROOT",
);
const targetPath = configuredPath(
process.env.CLAWSEC_SCANNER_TARGET,
installRoot,
"CLAWSEC_SCANNER_TARGET",
);
const stateFile = configuredPath(
process.env.CLAWSEC_SCANNER_STATE_FILE,
path.join(os.homedir(), ".openclaw", "clawsec-scanner-state.json"),
"CLAWSEC_SCANNER_STATE_FILE",
);
const scanIntervalSeconds = parsePositiveInteger(
process.env.CLAWSEC_SCANNER_INTERVAL,
DEFAULT_SCAN_INTERVAL_SECONDS,
);
const scanTimeout = parsePositiveInteger(
process.env.CLAWSEC_SCANNER_TIMEOUT,
DEFAULT_SCANNER_TIMEOUT,
);
const minSeverity = process.env.CLAWSEC_SCANNER_MIN_SEVERITY || DEFAULT_MIN_SEVERITY;
const outputFormat = process.env.CLAWSEC_SCANNER_FORMAT || "text";
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
const skipDeps = process.env.CLAWSEC_SKIP_DEPENDENCY_SCAN === "1";
const skipSast = process.env.CLAWSEC_SKIP_SAST === "1";
const skipDast = process.env.CLAWSEC_SKIP_DAST === "1";
const skipCve = process.env.CLAWSEC_SKIP_CVE_LOOKUP === "1";
if (allowUnsigned && !unsignedModeWarningShown) {
unsignedModeWarningShown = true;
console.warn(
"[clawsec-scanner-hook] CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. " +
"This bypass is for development only.",
);
}
const forceScan = toEventName(event) === "command:new";
const state = await loadState(stateFile);
if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
return;
}
const report = await runScanner(targetPath, {
skipDeps,
skipSast,
skipDast,
skipCve,
timeout: scanTimeout,
});
const nowIso = new Date().toISOString();
state.last_hook_scan = nowIso;
state.last_full_scan = nowIso;
if (!report) {
await persistState(stateFile, state);
return;
}
// Filter by minimum severity
const filteredVulns = report.vulnerabilities.filter((vuln) =>
shouldReportSeverity(vuln.severity, minSeverity),
);
// Deduplicate against known vulnerabilities
const dedupedReport = deduplicateVulnerabilities(
{ ...report, vulnerabilities: filteredVulns },
state.known_vulnerabilities,
);
// Update known vulnerabilities list
const allVulnIds = report.vulnerabilities.map((v) => v.id).filter((id) => id.trim() !== "");
state.known_vulnerabilities = Array.from(new Set([...state.known_vulnerabilities, ...allVulnIds]));
await persistState(stateFile, state);
// Write optional output file
const outputFile = process.env.CLAWSEC_SCANNER_OUTPUT_FILE;
if (outputFile) {
try {
await fs.writeFile(outputFile, JSON.stringify(report, null, 2), "utf8");
} catch (error) {
console.warn(`[clawsec-scanner-hook] failed to write output file: ${String(error)}`);
}
}
// Post findings to conversation if any new vulnerabilities
if (dedupedReport.vulnerabilities.length > 0) {
const alertMessage = buildAlertMessage(dedupedReport, outputFormat);
event.messages?.push({
role: "system",
content: `🔍 ClawSec Scanner detected ${dedupedReport.vulnerabilities.length} new vulnerabilities:\n\n${alertMessage}`,
});
}
};
export default handler;