mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
f9a7565d6f
* 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>
309 lines
9.1 KiB
TypeScript
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;
|