Files
clawsec/skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts
T
davida-ps f9a7565d6f Automated Vulnerability Scanner Skill (clawsec-scanner) (#101)
* auto-claude: subtask-1-1 - Create skill.json with SBOM, OpenClaw config, and required binaries

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Create SKILL.md with YAML frontmatter and documentation

* auto-claude: subtask-1-3 - Create CHANGELOG.md starting at version 0.1.0

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-4 - Create directory structure (scripts/, lib/, hooks/, test/)

* auto-claude: subtask-2-1 - Create lib/types.ts with Vulnerability and ScanReport interfaces

- Defined VulnerabilitySource type with 7 possible sources (npm-audit, pip-audit, osv, nvd, github, sast, dast)
- Defined SeverityLevel type with 5 severity levels (critical, high, medium, low, info)
- Created Vulnerability interface with all required fields: id, source, severity, package, version, title, description, references, discovered_at, and optional fixed_version
- Created ScanReport interface with scan_id, timestamp, target, vulnerabilities array, and summary counts
- Added HookEvent and HookContext types for OpenClaw hook integration
- Follows patterns from clawsec-suite advisory-guardian types

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Create lib/utils.mjs with subprocess execution and JSON parsing helpers

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-3 - Create lib/report.mjs for unified vulnerability re

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Create scripts/scan_dependencies.mjs for npm audit and pip-audit integration

- Implements npm audit JSON output parsing with non-zero exit handling
- Implements pip-audit JSON output parsing with -f json flag
- Handles missing package-lock.json/requirements.txt gracefully
- Checks for command availability (npm, pip-audit) before running
- Converts audit outputs to unified Vulnerability schema
- Generates ScanReport with UUID scan_id and timestamp
- Supports --target and --format (json|text) CLI flags
- Edge cases: missing files, unavailable commands, malformed JSON
- Verification passes: UUID scan_id matches pattern ^[0-9a-f-]{36}$

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Create scripts/query_cve_databases.mjs with OSV pr

Implemented CVE database integration with:
- queryOSV(): Primary CVE source using OSV API (free, no auth)
- queryNVD(): Fallback NVD API with 6s rate limiting (gated by CLAWSEC_NVD_API_KEY)
- queryGitHub(): Placeholder for future GitHub Advisory Database integration
- enrichVulnerability(): Multi-database enrichment pipeline
- Normalization to unified Vulnerability schema with severity, references, fixed versions
- Graceful error handling for network failures and API errors

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-5-1 - Create scripts/sast_analyzer.mjs to run Semgrep and Bandit

Implemented static analysis engine following scan_dependencies.mjs pattern:
- Runs Semgrep for JS/TS with --config auto and --json output
- Runs Bandit for Python with -r <path> -f json -c pyproject.toml
- Handles non-zero exit codes gracefully (tools exit 1 on findings)
- Parses JSON output and converts to unified Vulnerability schema
- Supports --target and --format CLI flags
- Gracefully handles missing tools (semgrep, bandit)
- Generates ScanReport with UUID scan_id and severity summary

Verification passed: JSON output with valid vulnerabilities array

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-6-1 - Create scripts/dast_runner.mjs with basic security test framework

- Implemented DAST framework with 4 security test cases:
  - DAST-001: Hook handler malicious input test (XSS, command injection, path traversal)
  - DAST-002: Hook handler timeout enforcement (30s default)
  - DAST-003: Hook handler resource limits (memory/CPU)
  - DAST-004: Hook handler event mutation safety
- Supports --target, --format (json|text), --timeout CLI flags
- Returns unified ScanReport with vulnerability schema
- Executes all test cases with configurable timeout
- Tests malicious input patterns: XSS, SQL injection, command injection, path traversal, null bytes, large payloads
- v1 scope: basic test framework for hook security testing (full agent workflow DAST is future work)

Verification:
-  Framework loads and executes 4 test cases
-  Timeout enforcement working (30s default, configurable via --timeout)
-  JSON output with valid scan_id
-  Text format output working
-  Help output displays usage information

* auto-claude: subtask-7-1 - Create scripts/runner.sh as main entry point with CLI flag parsing

- Orchestrates all scanning engines (dependency, SAST, DAST, CVE)
- Supports --target (required), --output, --format flags
- Merges reports from all scanners using jq
- Provides --help documentation
- Follows openclaw-audit-watchdog/scripts/runner.sh pattern
- Includes skip flags for selective scanning
- Verification: --help shows --target flag

* auto-claude: subtask-8-1 - Create hooks/clawsec-scanner-hook/HOOK.md with hook metadata

- Added YAML frontmatter with hook name, description, and OpenClaw events
- Documented hook purpose: periodic vulnerability scanning on agent:bootstrap and command:new
- Described four scanning engines: dependency, SAST, DAST, CVE lookup
- Added safety contract (non-blocking, read-only, configurable interval)
- Documented all environment variables (core config, CVE integration, selective scanning, advanced options)
- Listed required binaries (node, npm, python3, pip-audit, semgrep, bandit, jq, curl)
- Follows clawsec-advisory-guardian/HOOK.md pattern

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-8-2 - Create hooks/clawsec-scanner-hook/handler.ts with event.messages mutation

- Implement hook handler following clawsec-advisory-guardian pattern
- Add rate-limited scanning with configurable interval (default 24h)
- Support event types: agent:bootstrap and command:new
- Integrate with runner.sh for vulnerability scanning
- Deduplicate vulnerabilities using state file persistence
- Filter findings by minimum severity (default: medium)
- Push scan results to event.messages array
- Support selective scanning via environment variables
- Handle failures gracefully with partial results

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-8-3 - Create scripts/setup_scanner_hook.mjs for hook installation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-9-1 - Create test/dependency_scanner.test.mjs for dependency scanning tests

- Created test harness (test/lib/test_harness.mjs) with test utilities
- Created comprehensive test suite with 20 tests covering:
  - normalizeSeverity function (all severity levels)
  - safeJsonParse function (valid, invalid, empty inputs)
  - getTimestamp and generateUuid functions
  - commandExists function (found and not found cases)
  - generateReport function (empty and with vulnerabilities)
  - formatReportJson and formatReportText functions
  - Report structure validation
  - Temp directory creation and cleanup
- All tests pass successfully (20/20)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-9-2 - Create test/cve_integration.test.mjs for CVE database API tests

Added comprehensive CVE integration tests covering:
- OSV API query and normalization
- NVD API query with rate limiting
- GitHub Advisory Database placeholder
- Multi-source enrichment
- Error handling and network failures
- Vulnerability structure validation
- Multiple ecosystem support (npm, PyPI)

Tests gracefully handle network unavailability and skip API key-dependent tests.
All 20 tests passing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-9-3 - Create test/sast_engine.test.mjs for static analysis tests

- Added comprehensive test suite for SAST engine functionality
- Tests cover Semgrep and Bandit output parsing
- Validates severity normalization and vulnerability data structures
- Includes edge case handling for malformed JSON and missing fields
- All 16 tests passing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-10-2 - Run ESLint with zero warnings

- Add no-unused-vars rule with argsIgnorePattern to .mjs files in ESLint config
- Prefix unused parameters with underscore in handler.ts, dast_runner.mjs, query_cve_databases.mjs
- Remove unused error binding in handler.ts catch block
- Remove unused result variable in cve_integration.test.mjs
- Remove unused SAMPLE_OSV_VULN and SAMPLE_NVD_CVE constants
- Remove unused safeJsonParse import from query_cve_databases.mjs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(clawsec-scanner): resolve baz logical scanner findings

* fix(clawsec-scanner): make scanner state parsing type-safe

* chore(clawsec-scanner): bump version to 0.0.1

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-09 21:16:22 +02:00

309 lines
9.1 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> => {
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;