
Clawsec Scanner
- 179 installs
- 1.1k repo stars
- Updated August 4, 2026
- prompt-security/clawsec
Scan Claude skills, prompts, and agent configs in CI or pre-release gates to surface injection, data-exfil, and policy violations from prompt-security/clawsec.
About
clawsec-scanner from prompt-security/clawsec runs automated security audits on prompts, skills, and agent configurations, flagging injection paths, exfiltration patterns, and policy breaches before release.
- Automated clawsec policy scanning
- Detects prompt injection and unsafe tools
- CI-friendly security gate for agents
- Maps findings to compliance-style rules
- Complements nanoclaw runtime guards
Clawsec Scanner by the numbers
- 179 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #817 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/prompt-security/clawsec --skill clawsec-scannerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 179 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 4, 2026 |
| Repository | prompt-security/clawsec ↗ |
What it does
Scan Claude skills, prompts, and agent configs in CI or pre-release gates to surface injection, data-exfil, and policy violations from prompt-security/clawsec.
Files
ClawSec Scanner
Comprehensive security scanner for agent platforms that automates vulnerability detection across multiple dimensions:
- Dependency Scanning: Analyzes npm and Python dependencies using
npm auditandpip-auditwith structured JSON output parsing - CVE Database Integration: Queries OSV (primary), NVD 2.0, and GitHub Advisory Database for vulnerability enrichment
- SAST Analysis: Static code analysis using Semgrep (JavaScript/TypeScript) and Bandit (Python) to detect hardcoded secrets, command injection, path traversal, and unsafe deserialization
- DAST Framework: Agent-specific static analysis of OpenClaw hook metadata and handler source without importing or invoking target code
- Unified Reporting: Consolidated vulnerability reports with severity classification and remediation guidance
- Continuous Monitoring: OpenClaw hook integration for automated periodic scanning
Vercel Skills Installation
Install with the Vercel Skills CLI for this harness:
npx skills add prompt-security/clawsec --skill clawsec-scanner -a openclaw -yFeatures
Multi-Engine Scanning
The scanner orchestrates four complementary scan types to provide comprehensive vulnerability coverage:
1. Dependency Scanning
- Executes
npm audit --jsonandpip-audit -f jsonas subprocesses - Parses structured output to extract CVE IDs, severity, affected versions
- Handles edge cases: missing package-lock.json, zero vulnerabilities, malformed JSON
2. CVE Database Queries
- OSV API (primary): Free, no authentication, broad ecosystem support (npm, PyPI, Go, Maven)
- NVD 2.0 (optional): Requires API key to avoid 6-second rate limiting
- GitHub Advisory Database (optional): GraphQL API with OAuth token
- Normalizes all API responses to unified
Vulnerabilityschema
3. Static Analysis (SAST)
- Semgrep for JavaScript/TypeScript: Detects security issues using
--config autoor--config p/security-audit - Bandit for Python: Leverages existing
pyproject.tomlconfiguration - Identifies: hardcoded secrets (API keys, tokens), command injection (
eval,exec), path traversal, unsafe deserialization
4. Dynamic Analysis (DAST)
- Static hook inspection for OpenClaw hook handlers discovered from
HOOK.mdmetadata - Verifies coverage and source-level risk signals without importing, transpiling, or invoking target handlers
- Note: Traditional web DAST tools (ZAP, Burp) do not apply to agent platforms - this provides agent-specific testing
Unified Reporting
All scan types emit a consistent ScanReport JSON schema:
{
scan_id: string; // UUID
timestamp: string; // ISO 8601
target: string; // Scanned path
vulnerabilities: Vulnerability[];
summary: {
critical: number;
high: number;
medium: number;
low: number;
info: number;
}
}Each Vulnerability object includes:
id: CVE-2023-12345 or GHSA-xxxx-yyyy-zzzzsource: npm-audit | pip-audit | osv | nvd | github | sast | dastseverity: critical | high | medium | low | infopackage: Package name (or 'N/A' for SAST/DAST)version: Affected versionfixed_version: First version with fix (if available)title: Short descriptiondescription: Full advisory textreferences: URLs for more infodiscovered_at: ISO 8601 timestamp
OpenClaw Integration
Automated continuous monitoring via hook:
- Runs scanner on configurable interval (default: 86400s / 24 hours)
- Triggers on
agent:bootstrapandcommand:newevents - Posts findings to
event.messagesarray with severity summary - Rate-limited by
CLAWSEC_SCANNER_INTERVALenvironment variable
Installation
Prerequisites
Verify required binaries are available:
# Core runtimes
node --version # v20+
npm --version
python3 --version # 3.10+
# Scanning tools
pip-audit --version # Install: uv pip install pip-audit
semgrep --version # Install: pip install semgrep OR brew install semgrep
bandit --version # Install: uv pip install bandit
# Utilities
jq --version
curl --versionOption A: Via clawhub (recommended)
npx clawhub@latest install clawsec-scannerOption B: Manual installation with verification
set -euo pipefail
VERSION="${SKILL_VERSION:?Set SKILL_VERSION (e.g. 0.1.0)}"
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
DEST="$INSTALL_ROOT/clawsec-scanner"
BASE="https://github.com/prompt-security/clawsec/releases/download/clawsec-scanner-v${VERSION}"
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TEMP_DIR"' EXIT
# Pinned release-signing public key
# Fingerprint (SHA-256 of SPKI DER): 711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8
cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM'
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
-----END PUBLIC KEY-----
PEM
ZIP_NAME="clawsec-scanner-v${VERSION}.zip"
# Download release archive + signed checksums
curl -fsSL "$BASE/$ZIP_NAME" -o "$TEMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TEMP_DIR/checksums.sig"
# Verify checksums manifest signature
openssl base64 -d -A -in "$TEMP_DIR/checksums.sig" -out "$TEMP_DIR/checksums.sig.bin"
if ! openssl pkeyutl -verify \
-pubin \
-inkey "$TEMP_DIR/release-signing-public.pem" \
-sigfile "$TEMP_DIR/checksums.sig.bin" \
-rawin \
-in "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
echo "ERROR: checksums.json signature verification failed" >&2
exit 1
fi
EXPECTED_SHA="$(jq -r '.archive.sha256 // empty' "$TEMP_DIR/checksums.json")"
if [ -z "$EXPECTED_SHA" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
ACTUAL_SHA="$(shasum -a 256 "$TEMP_DIR/$ZIP_NAME" | awk '{print $1}')"
if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then
echo "ERROR: Archive checksum mismatch" >&2
exit 1
fi
echo "Checksums verified. Installing..."
mkdir -p "$INSTALL_ROOT"
rm -rf "$DEST"
unzip -q "$TEMP_DIR/$ZIP_NAME" -d "$INSTALL_ROOT"
chmod 600 "$DEST/skill.json"
find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
echo "Installed clawsec-scanner v${VERSION} to: $DEST"
echo "Next step: Run a scan or set up continuous monitoring"Usage
On-Demand CLI Scanning
SCANNER_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-scanner"
# Scan all skills with JSON output
"$SCANNER_DIR/scripts/runner.sh" --target ./skills/ --output report.json --format json
# Scan specific directory with human-readable output
"$SCANNER_DIR/scripts/runner.sh" --target ./my-skill/ --format text
# Check available flags
"$SCANNER_DIR/scripts/runner.sh" --helpCLI Flags:
--target <path>: Directory to scan (required)--output <file>: Write results to file (optional, defaults to stdout)--format <json|text>: Output format (default: json)--check: Verify all required binaries are installed
OpenClaw Hook Setup (Continuous Monitoring)
Enable automated periodic scanning:
SCANNER_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-scanner"
node "$SCANNER_DIR/scripts/setup_scanner_hook.mjs"This creates a hook that:
- Scans on
agent:bootstrapandcommand:newevents - Respects
CLAWSEC_SCANNER_INTERVALrate limiting (default: 86400 seconds / 24 hours) - Posts findings to conversation with severity summary
- Recommends remediation for high/critical vulnerabilities
Restart the OpenClaw gateway after enabling the hook, then run /new to trigger an immediate scan.
Environment Variables
# Optional - NVD API key to avoid rate limiting (6-second delays without key)
export CLAWSEC_NVD_API_KEY="your-nvd-api-key"
# Optional - GitHub OAuth token for Advisory Database queries
export GITHUB_TOKEN="ghp_your_token_here"
# Optional - Scanner hook interval in seconds (default: 86400 / 24 hours)
export CLAWSEC_SCANNER_INTERVAL="86400"
# Optional - Allow unsigned advisory feed during development (from clawsec-suite)
export CLAWSEC_ALLOW_UNSIGNED_FEED="1"Architecture
Modular Design
Each scan type is an independent module that can run standalone or as part of unified scan:
scripts/runner.sh # Orchestration layer
├── scan_dependencies.mjs # npm audit + pip-audit
├── query_cve_databases.mjs # OSV/NVD/GitHub API queries
├── sast_analyzer.mjs # Semgrep + Bandit static analysis
├── dast_runner.mjs # Static hook inspection orchestration
└── dast_hook_executor.mjs # Static hook source inspection helper
lib/
├── report.mjs # Result aggregation and formatting
├── utils.mjs # Subprocess exec, JSON parsing, error handling
└── types.ts # TypeScript schema definitions
hooks/clawsec-scanner-hook/
├── HOOK.md # OpenClaw hook metadata
└── handler.ts # Periodic scan triggerFail-Open Philosophy
The scanner prioritizes availability over strict failure propagation:
- Network failures → emit partial results, log warnings
- Missing tools → skip that scan type, continue with others
- Malformed JSON → parse what's valid, log errors
- API rate limits → implement exponential backoff, fallback to other sources
- Zero vulnerabilities → emit success report with empty array
Critical failures that exit immediately:
- Target path does not exist
- No scanning tools available (all bins missing)
- Concurrent scan detected (lockfile present)
Subprocess Execution Pattern
All external tools run as subprocesses with structured JSON output:
import { spawn } from 'node:child_process';
// Example: npm audit execution
const proc = spawn('npm', ['audit', '--json'], {
cwd: targetPath,
stdio: ['ignore', 'pipe', 'pipe']
});
// Handle non-zero exit codes gracefully
// npm audit exits 1 when vulnerabilities found (not an error!)
proc.on('close', code => {
if (code !== 0 && stderr.includes('ERR!')) {
// Actual error
reject(new Error(stderr));
} else {
// Vulnerabilities found or success
resolve(JSON.parse(stdout));
}
});Troubleshooting
Common Issues
"Missing package-lock.json" warning
npm auditrequires lockfile to run- Run
npm installin target directory to generate - Scanner continues with other scan types if npm audit fails
"NVD API rate limit exceeded"
- Set
CLAWSEC_NVD_API_KEYenvironment variable - Without API key: 6-second delays enforced between requests
- OSV API used as primary source (no rate limits)
"pip-audit not found"
- Install:
uv pip install pip-auditorpip install pip-audit - Verify:
which pip-audit - Add to PATH if installed in non-standard location
"Semgrep binary missing"
- Install:
pip install semgrepORbrew install semgrep - Requires Python 3.8+ runtime
- Alternative: use Docker image
returntocorp/semgrep
"DAST static coverage finding"
- The DAST harness does not execute target hook handlers.
- JavaScript and TypeScript hook files are read as source and reported with
info-level static coverage findings. - Review any listed static signals manually when deciding whether a hook needs deeper sandboxed testing.
"Concurrent scan detected"
- Lockfile exists:
/tmp/clawsec-scanner.lock - Wait for running scan to complete or manually remove lockfile
- Prevents overlapping scans that could produce inconsistent results
Verification
Check scanner is working correctly:
# Verify required binaries
./scripts/runner.sh --check
# Run unit tests
node test/dependency_scanner.test.mjs
node test/cve_integration.test.mjs
node test/sast_engine.test.mjs
node test/dast_harness.test.mjs
# Validate skill structure
python ../../utils/validate_skill.py .
# Scan test fixtures (should detect known vulnerabilities)
./scripts/runner.sh --target test/fixtures/ --format textDevelopment
Running Tests
# All tests (vanilla Node.js, no framework)
for test in test/*.test.mjs; do
node "$test" || exit 1
done
# Individual test suites
node test/dependency_scanner.test.mjs # Dependency scanning
node test/cve_integration.test.mjs # CVE database APIs
node test/sast_engine.test.mjs # Static analysis
node test/dast_harness.test.mjs # DAST static hook inspectionLinting
# JavaScript/TypeScript
npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0
# Python (Bandit already configured in pyproject.toml)
ruff check .
bandit -r . -ll
# Shell scripts
shellcheck scripts/*.shAdding Custom Semgrep Rules
Create custom rules in .semgrep/rules/:
rules:
- id: custom-security-rule
pattern: dangerous_function($ARG)
message: Avoid dangerous_function - use safe_alternative instead
severity: WARNING
languages: [javascript, typescript]Update scripts/sast_analyzer.mjs to include custom rules:
const proc = spawn('semgrep', [
'scan',
'--config', 'auto',
'--config', '.semgrep/rules/', // Add custom rules
'--json',
targetPath
]);Integration with ClawSec Suite
The scanner works standalone or as part of the ClawSec ecosystem:
- clawsec-suite: Meta-skill that can install and manage clawsec-scanner
- clawsec-feed: Advisory feed for malicious skill detection (complementary)
- openclaw-audit-watchdog: Cron-based audit automation (similar pattern)
Install the full ClawSec suite:
npx clawhub@latest install clawsec-suite
# Then use clawsec-suite to discover and install clawsec-scannerSecurity Considerations
Scanner Security
- No hardcoded secrets in scanner code
- API keys read from environment variables only (never logged or committed)
- Subprocess arguments use arrays to prevent shell injection
- All external tool output parsed with try/catch error handling
Vulnerability Prioritization
Critical/High severity findings should be addressed immediately:
- Known exploits in dependencies (CVSS 9.0+)
- Hardcoded API keys or credentials in code
- Command injection vulnerabilities
- Path traversal without validation
Medium/Low severity findings can be addressed in normal sprint cycles:
- Outdated dependencies without known exploits
- Missing security headers
- Weak cryptography usage
Info findings are advisory only:
- Deprecated API usage
- Code quality issues flagged by linters
Roadmap
v0.0.4 (Current)
- [x] Dependency scanning (npm audit, pip-audit)
- [x] CVE database integration (OSV, NVD, GitHub Advisory)
- [x] SAST analysis (Semgrep, Bandit)
- [x] Static OpenClaw hook inspection for DAST without target code execution
- [x] Unified JSON reporting
- [x] OpenClaw hook integration
Future Enhancements
- [ ] Automatic remediation (dependency upgrades, code fixes)
- [ ] SARIF output format for GitHub Code Scanning integration
- [ ] Web dashboard for vulnerability tracking over time
- [ ] CI/CD GitHub Action for PR blocking on high-severity findings
- [ ] Container image scanning (Docker, OCI)
- [ ] Infrastructure-as-Code scanning (Terraform, CloudFormation)
- [ ] Comprehensive agent workflow DAST (requires deeper platform integration)
Contributing
Found a security issue? Please report privately to security@prompt.security.
For feature requests and bug reports, open an issue at: https://github.com/prompt-security/clawsec/issues
License
AGPL-3.0-or-later
See LICENSE file in repository root for full text.
Resources
- ClawSec Homepage: https://clawsec.prompt.security
- Documentation: https://clawsec.prompt.security/scanner
- GitHub Repository: https://github.com/prompt-security/clawsec
- OSV API Docs: https://osv.dev/docs/
- NVD API Docs: https://nvd.nist.gov/developers/vulnerabilities
- Semgrep Registry: https://semgrep.dev/explore
- Bandit Documentation: https://bandit.readthedocs.io/
Changelog
[0.0.5] - 2026-06-10
Changed
- Re-released skill package with updated marketplace grouping and signed release trust artifacts for Vercel-compatible skill installation.
[0.0.4] - 2026-06-07
Security
- Replaced DAST target hook execution with static hook source inspection so scanner runs never import, transpile, or invoke untrusted handler code.
[0.0.3] - 2026-05-13
Changed
- Re-release skill payload metadata after excluding test-only files from release SBOMs and archives.
All notable changes to the ClawSec Scanner will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.0.2] - 2026-03-10
Changed
- Replaced simulated DAST checks with real OpenClaw hook execution harness testing
- Updated DAST semantics so high-severity findings are emitted for actual hook execution failures/timeouts, not static payload pattern matches
- Reclassified DAST harness capability limitations (for example missing TypeScript compiler for
.tshooks) toinfocoverage findings instead of high severity - Added DAST harness mode guard to prevent recursive scanner execution when hook handlers are tested in isolation
Added
- New DAST helper executor script for isolated per-hook execution and timeout enforcement
- DAST harness regression tests covering no-false-positive baseline and malicious-input crash detection
[0.0.1] - 2026-02-27
Added
- Initial release of ClawSec Scanner skill
- Automated vulnerability scanning for OpenClaw skill installations
- Integration with advisory feed for real-time security alerts
- Support for scanning skill dependencies and detecting known CVEs
- Configurable scan policies and risk thresholds
- Detailed vulnerability reporting with remediation guidance
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;
ClawSec Scanner Hook
This hook performs comprehensive vulnerability scanning on installed skills and their dependencies on:
agent:bootstrapcommand:new
When triggered, it runs all configured scanning engines (dependency scan, SAST, DAST, CVE database lookup) and posts findings as conversation messages. Scans are rate-limited by configurable interval to avoid performance impact.
Scanning Capabilities
The hook orchestrates four independent scanning engines:
1. Dependency Scanning: Executes npm audit and pip-audit to detect known vulnerabilities in JavaScript and Python dependencies 2. SAST (Static Analysis): Runs Semgrep (JS/TS) and Bandit (Python) to detect security issues like hardcoded secrets, command injection, and path traversal 3. CVE Database Lookup: Queries OSV API (primary), NVD 2.0 (optional), and GitHub Advisory Database (optional) for vulnerability enrichment 4. DAST (Dynamic Analysis): Executes real OpenClaw hook handlers in an isolated harness and tests malicious-input resilience, timeout behavior, output bounds, and event mutation safety
Safety Contract
- The hook does not modify or delete skills.
- It only reports findings and provides remediation guidance.
- Scanning is non-blocking and runs on a configurable interval (default 24 hours).
- Failed scans (network errors, missing tools) produce warnings but do not block execution.
- Findings are deduplicated to avoid alert fatigue.
Optional Environment Variables
Core Configuration
CLAWSEC_SCANNER_INTERVAL: Minimum interval between hook scans in seconds (default86400/ 24 hours).CLAWSEC_SCANNER_TARGET: Override default scan target path (default: installed skills root).CLAWSEC_SCANNER_STATE_FILE: Override state file path for deduplication (default~/.openclaw/clawsec-scanner-state.json).CLAWSEC_INSTALL_ROOT: Override installed skills root directory.
CVE Database Integration
CLAWSEC_NVD_API_KEY: NVD API key for rate-limit-free access (without this, 6-second delays apply).GITHUB_TOKEN: GitHub OAuth token for GitHub Advisory Database queries (optional enhancement).
Selective Scanning
CLAWSEC_SKIP_DEPENDENCY_SCAN: Set to1to disable dependency scanning (npm audit, pip-audit).CLAWSEC_SKIP_SAST: Set to1to disable static analysis (Semgrep, Bandit).CLAWSEC_SKIP_DAST: Set to1to disable dynamic analysis (hook security tests).CLAWSEC_SKIP_CVE_LOOKUP: Set to1to disable CVE database enrichment.
Advanced Options
CLAWSEC_SCANNER_TIMEOUT: Maximum scan duration in seconds before timeout (default300/ 5 minutes).CLAWSEC_SCANNER_FORMAT: Output format for findings (jsonortext, defaulttext).CLAWSEC_SCANNER_MIN_SEVERITY: Minimum severity to report (critical,high,medium,low,info, defaultmedium).CLAWSEC_SCANNER_OUTPUT_FILE: Optional path to write full scan report JSON (default: conversation only).
Required Binaries
The hook requires the following binaries to be available on PATH:
node(20+) - JavaScript runtimenpm- For npm audit executionpython3(3.10+) - Python runtimepip-audit- Python dependency scannersemgrep- JavaScript/TypeScript static analysisbandit- Python static analysisjq- JSON parsing and mergingcurl- API requests (fallback)
Missing binaries will be logged as warnings; available tools will still run.
import { generateUuid, getTimestamp } from "./utils.mjs";
/**
* @typedef {import('./types.ts').Vulnerability} Vulnerability
* @typedef {import('./types.ts').ScanReport} ScanReport
* @typedef {import('./types.ts').SeverityLevel} SeverityLevel
*/
/**
* Generate a unified vulnerability report from scan results.
*
* @param {Vulnerability[]} vulnerabilities - Array of detected vulnerabilities
* @param {string} target - Target path that was scanned
* @returns {ScanReport}
*/
export function generateReport(vulnerabilities, target = ".") {
const summary = {
critical: 0,
high: 0,
medium: 0,
low: 0,
info: 0,
};
// Count vulnerabilities by severity
for (const vuln of vulnerabilities) {
const severity = vuln.severity;
if (severity in summary) {
summary[severity]++;
}
}
return {
scan_id: generateUuid(),
timestamp: getTimestamp(),
target,
vulnerabilities,
summary,
};
}
/**
* Format a scan report as JSON string.
*
* @param {ScanReport} report - Scan report to format
* @param {boolean} pretty - Whether to pretty-print JSON
* @returns {string}
*/
export function formatReportJson(report, pretty = true) {
return pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
}
/**
* Format a scan report as human-readable text.
*
* @param {ScanReport} report - Scan report to format
* @returns {string}
*/
export function formatReportText(report) {
const lines = [];
// Header
lines.push("═══════════════════════════════════════════════════════════════");
lines.push(" VULNERABILITY SCAN REPORT");
lines.push("═══════════════════════════════════════════════════════════════");
lines.push("");
lines.push(`Scan ID: ${report.scan_id}`);
lines.push(`Timestamp: ${report.timestamp}`);
lines.push(`Target: ${report.target}`);
lines.push("");
// Summary
lines.push("───────────────────────────────────────────────────────────────");
lines.push("SUMMARY");
lines.push("───────────────────────────────────────────────────────────────");
lines.push("");
const total = report.vulnerabilities.length;
const { critical, high, medium, low, info } = report.summary;
lines.push(`Total Vulnerabilities: ${total}`);
lines.push("");
if (critical > 0) {
lines.push(` 🔴 Critical: ${critical}`);
}
if (high > 0) {
lines.push(` 🟠 High: ${high}`);
}
if (medium > 0) {
lines.push(` 🟡 Medium: ${medium}`);
}
if (low > 0) {
lines.push(` 🔵 Low: ${low}`);
}
if (info > 0) {
lines.push(` ⚪ Info: ${info}`);
}
if (total === 0) {
lines.push(" ✓ No vulnerabilities detected");
}
lines.push("");
// Detailed findings
if (report.vulnerabilities.length > 0) {
lines.push("───────────────────────────────────────────────────────────────");
lines.push("DETAILED FINDINGS");
lines.push("───────────────────────────────────────────────────────────────");
lines.push("");
// Group vulnerabilities by severity
const bySeverity = {
critical: [],
high: [],
medium: [],
low: [],
info: [],
};
for (const vuln of report.vulnerabilities) {
bySeverity[vuln.severity].push(vuln);
}
// Display in order: critical -> high -> medium -> low -> info
const severityOrder = ["critical", "high", "medium", "low", "info"];
for (const severity of severityOrder) {
const vulns = bySeverity[severity];
if (vulns.length === 0) continue;
const severityIcon = getSeverityIcon(severity);
lines.push(`${severityIcon} ${severity.toUpperCase()}`);
lines.push("");
for (const vuln of vulns) {
lines.push(` ID: ${vuln.id}`);
lines.push(` Package: ${vuln.package} @ ${vuln.version}`);
if (vuln.fixed_version) {
lines.push(` Fix: ${vuln.fixed_version}`);
}
lines.push(` Source: ${vuln.source}`);
lines.push(` Title: ${vuln.title}`);
// Wrap description at 60 chars
const descLines = wrapText(vuln.description, 60);
lines.push(" Description:");
for (const line of descLines) {
lines.push(` ${line}`);
}
if (vuln.references.length > 0) {
lines.push(" References:");
for (const ref of vuln.references.slice(0, 3)) {
lines.push(` - ${ref}`);
}
if (vuln.references.length > 3) {
lines.push(` ... and ${vuln.references.length - 3} more`);
}
}
lines.push("");
}
}
}
// Recommendations
lines.push("───────────────────────────────────────────────────────────────");
lines.push("RECOMMENDATIONS");
lines.push("───────────────────────────────────────────────────────────────");
lines.push("");
if (critical > 0 || high > 0) {
lines.push("⚠️ URGENT: Critical or high severity vulnerabilities detected!");
lines.push("");
lines.push("Recommended actions:");
lines.push(" 1. Review all critical and high severity findings immediately");
lines.push(" 2. Update vulnerable dependencies to fixed versions");
lines.push(" 3. Run scanner again to verify remediation");
lines.push("");
} else if (medium > 0) {
lines.push("⚠️ Medium severity vulnerabilities detected.");
lines.push("");
lines.push("Recommended actions:");
lines.push(" 1. Review findings and assess impact on your use case");
lines.push(" 2. Plan updates during next maintenance window");
lines.push("");
} else if (low > 0 || info > 0) {
lines.push("✓ No critical or high severity vulnerabilities detected.");
lines.push("");
lines.push("Recommended actions:");
lines.push(" 1. Review low/info findings for awareness");
lines.push(" 2. Consider updates when convenient");
lines.push("");
} else {
lines.push("✓ No vulnerabilities detected. Your code is clean!");
lines.push("");
}
lines.push("═══════════════════════════════════════════════════════════════");
return lines.join("\n");
}
/**
* Get emoji icon for severity level.
*
* @param {SeverityLevel} severity - Severity level
* @returns {string}
*/
function getSeverityIcon(severity) {
const icons = {
critical: "🔴",
high: "🟠",
medium: "🟡",
low: "🔵",
info: "⚪",
};
return icons[severity] || "⚪";
}
/**
* Wrap text to specified width.
*
* @param {string} text - Text to wrap
* @param {number} width - Maximum line width
* @returns {string[]}
*/
function wrapText(text, width) {
const words = text.split(/\s+/);
const lines = [];
let currentLine = "";
for (const word of words) {
if (currentLine.length + word.length + 1 <= width) {
currentLine += (currentLine ? " " : "") + word;
} else {
if (currentLine) {
lines.push(currentLine);
}
currentLine = word;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [""];
}
export type VulnerabilitySource = 'npm-audit' | 'pip-audit' | 'osv' | 'nvd' | 'github' | 'sast' | 'dast';
export type SeverityLevel = 'critical' | 'high' | 'medium' | 'low' | 'info';
export interface Vulnerability {
id: string;
source: VulnerabilitySource;
severity: SeverityLevel;
package: string;
version: string;
fixed_version?: string;
title: string;
description: string;
references: string[];
discovered_at: string;
}
export interface ScanReport {
scan_id: string;
timestamp: string;
target: string;
vulnerabilities: Vulnerability[];
summary: {
critical: number;
high: number;
medium: number;
low: number;
info: number;
};
}
export type HookEvent = {
type?: string;
action?: string;
messages?: Array<{
role: string;
content: string;
}>;
};
export type HookContext = {
skillPath?: string;
agentPlatform?: string;
[key: string]: unknown;
};
import { spawn } from "node:child_process";
/**
* @param {unknown} value
* @returns {value is Record<string, unknown>}
*/
export function isObject(value) {
return typeof value === "object" && value !== null;
}
/**
* Execute a command as a subprocess and return its output.
*
* NOTE: npm audit exits non-zero when vulnerabilities are found.
* Check stderr for actual errors vs. normal vulnerability reports.
*
* @param {string} cmd - Command to execute
* @param {string[]} args - Command arguments
* @param {{env?: Record<string, string>, cwd?: string}} [options] - Execution options
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
*/
export function execCommand(cmd, args, options = {}) {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...options.env },
cwd: options.cwd,
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d) => {
stdout += d;
});
proc.stderr.on("data", (d) => {
stderr += d;
});
proc.on("close", (code) => {
// npm audit and other security tools exit non-zero when vulnerabilities found
// Check stderr for actual errors (ERR! pattern) vs. normal findings
if (code !== 0 && stderr.includes("ERR!")) {
reject(new Error(stderr));
} else {
resolve({ code, stdout, stderr });
}
});
proc.on("error", (error) => {
reject(error);
});
});
}
/**
* Safely parse JSON string with error handling.
*
* @param {string} jsonString - JSON string to parse
* @param {{fallback?: unknown, label?: string}} [options] - Parse options
* @returns {unknown}
*/
export function safeJsonParse(jsonString, { fallback = null, label = "JSON" } = {}) {
const raw = String(jsonString ?? "").trim();
if (!raw) return fallback;
try {
return JSON.parse(raw);
} catch (error) {
if (error instanceof Error) {
console.warn(`Failed to parse ${label}: ${error.message}`);
}
return fallback;
}
}
/**
* Normalize severity levels from different security tools to standard levels.
*
* @param {string} severity - Severity string from security tool
* @returns {'critical' | 'high' | 'medium' | 'low' | 'info'}
*/
export function normalizeSeverity(severity) {
const normalized = String(severity ?? "")
.trim()
.toLowerCase();
if (normalized.includes("critical")) return "critical";
if (normalized.includes("high")) return "high";
if (normalized.includes("moderate") || normalized.includes("medium")) return "medium";
if (normalized.includes("low")) return "low";
return "info";
}
/**
* @param {string[]} values
* @returns {string[]}
*/
export function uniqueStrings(values) {
return Array.from(new Set(values));
}
/**
* Generate a simple UUID v4.
*
* @returns {string}
*/
export function generateUuid() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Get current ISO 8601 timestamp.
*
* @returns {string}
*/
export function getTimestamp() {
return new Date().toISOString();
}
/**
* Check if a command exists in PATH.
*
* @param {string} command - Command name to check
* @returns {Promise<boolean>}
*/
export async function commandExists(command) {
try {
const { code } = await execCommand("which", [command]);
return code === 0;
} catch {
return false;
}
}
Clawsec Scanner
Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific static hook inspection for OpenClaw hooks.
Vercel Skills Installation
Install with the Vercel Skills CLI for this harness:
npx skills add prompt-security/clawsec --skill clawsec-scanner -a openclaw -y#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
function parseArgs(argv) {
const parsed = {
handler: "",
exportName: "default",
eventB64: "",
contextB64: "",
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--handler") {
parsed.handler = String(argv[i + 1] ?? "").trim();
i += 1;
continue;
}
if (token === "--export") {
parsed.exportName = String(argv[i + 1] ?? "default").trim() || "default";
i += 1;
continue;
}
if (token === "--event") {
parsed.eventB64 = String(argv[i + 1] ?? "").trim();
i += 1;
continue;
}
if (token === "--context") {
parsed.contextB64 = String(argv[i + 1] ?? "").trim();
i += 1;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
if (!parsed.handler) {
throw new Error("Missing required --handler");
}
return parsed;
}
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function readHookSource(handlerPath) {
const fullPath = path.resolve(handlerPath);
const exists = await fileExists(fullPath);
if (!exists) {
throw new Error(`Hook handler does not exist: ${fullPath}`);
}
const ext = path.extname(fullPath).toLowerCase();
const allowedExtensions = new Set([".cjs", ".js", ".mjs", ".ts"]);
if (!allowedExtensions.has(ext)) {
throw new Error(`Unsupported hook handler extension: ${ext || "(none)"}`);
}
const source = await fs.readFile(fullPath, "utf8");
return { fullPath, ext, source };
}
function detectHandlerExport(source, exportName) {
if (exportName && exportName !== "default") {
const escaped = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`export\\s+(?:async\\s+)?function\\s+${escaped}\\b|export\\s*\\{[^}]*\\b${escaped}\\b`, "m").test(source);
}
return (
/\bexport\s+default\b/m.test(source) ||
/\bexport\s+(?:async\s+)?function\s+handler\b/m.test(source) ||
/\bmodule\.exports\s*=|\bexports\.handler\s*=/m.test(source)
);
}
function collectRiskSignals(source) {
const rules = [
["child_process", /\bchild_process\b|\bfrom\s+["']node:child_process["']|\brequire\(["']child_process["']\)/m],
["dynamic-import", /\bimport\s*\(/m],
["eval", /\beval\s*\(|\bnew\s+Function\s*\(/m],
["shell-command", /\b(?:exec|spawn|execFile|fork)\s*\(/m],
["environment-access", /\bprocess\.env\b/m],
];
const signals = [];
for (const [name, pattern] of rules) {
if (pattern.test(source)) {
signals.push(name);
}
}
return signals;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const startedAt = Date.now();
try {
const inspected = await readHookSource(args.handler);
const payload = {
ok: true,
static_only: true,
duration_ms: Date.now() - startedAt,
handler_path: inspected.fullPath,
handler_extension: inspected.ext,
source_bytes: Buffer.byteLength(inspected.source, "utf8"),
source_lines: inspected.source.split(/\r?\n/).length,
handler_export_declared: detectHandlerExport(inspected.source, args.exportName),
risk_signals: collectRiskSignals(inspected.source),
};
process.stdout.write(JSON.stringify(payload));
} catch (error) {
const payload = {
ok: false,
static_only: true,
duration_ms: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
};
process.stdout.write(JSON.stringify(payload));
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`);
process.exit(1);
});
#!/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();
}
import { normalizeSeverity, getTimestamp, uniqueStrings } from '../lib/utils.mjs';
/**
* Query OSV API for vulnerability data.
* OSV is the primary CVE source (free, no auth, broad ecosystem support).
*
* @param {string} packageName - Package name (e.g., 'lodash')
* @param {string} ecosystem - Ecosystem identifier (e.g., 'npm', 'PyPI')
* @param {string} [version] - Optional specific version to check
* @returns {Promise<import('../lib/types.ts').Vulnerability[]>}
*/
export async function queryOSV(packageName, ecosystem, version = undefined) {
const url = 'https://api.osv.dev/v1/query';
const requestBody = {
package: {
name: packageName,
ecosystem: ecosystem,
},
};
if (version) {
requestBody.version = version;
}
try {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
const response = await globalThis.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal,
});
globalThis.clearTimeout(timeout);
if (!response.ok) {
console.warn(`OSV API returned status ${response.status} for ${packageName}`);
return [];
}
const data = await response.json();
const vulns = data.vulns || [];
return vulns.map((vuln) => normalizeOSVVulnerability(vuln, packageName, version || '*'));
} catch (error) {
if (error instanceof Error) {
console.warn(`OSV API error for ${packageName}: ${error.message}`);
}
return [];
}
}
/**
* Query NVD API 2.0 for CVE data.
* Gated behind CLAWSEC_NVD_API_KEY environment variable.
* Enforces 6-second rate limiting without API key.
*
* @param {string} cveId - CVE identifier (e.g., 'CVE-2023-12345')
* @returns {Promise<import('../lib/types.ts').Vulnerability | null>}
*/
export async function queryNVD(cveId) {
const apiKey = process.env.CLAWSEC_NVD_API_KEY;
const url = `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${cveId}`;
const headers = {};
if (apiKey) {
headers['apiKey'] = apiKey;
}
try {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 15000);
const response = await globalThis.fetch(url, {
method: 'GET',
headers,
signal: controller.signal,
});
globalThis.clearTimeout(timeout);
// Rate limiting: 6-second delay required WITHOUT API key
if (!apiKey) {
await new Promise((r) => globalThis.setTimeout(r, 6000));
}
if (!response.ok) {
console.warn(`NVD API returned status ${response.status} for ${cveId}`);
return null;
}
const data = await response.json();
if (!data.vulnerabilities || data.vulnerabilities.length === 0) {
return null;
}
const cveItem = data.vulnerabilities[0].cve;
return normalizeNVDVulnerability(cveItem);
} catch (error) {
if (error instanceof Error) {
console.warn(`NVD API error for ${cveId}: ${error.message}`);
}
return null;
}
}
/**
* Query GitHub Advisory Database (optional - requires OAuth token).
* Currently a placeholder for future implementation.
*
* @param {string} _packageName - Package name
* @param {string} _ecosystem - Ecosystem (e.g., 'npm', 'pip')
* @returns {Promise<import('../lib/types.ts').Vulnerability[]>}
*/
export async function queryGitHub(_packageName, _ecosystem) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.warn('GitHub Advisory Database query skipped: GITHUB_TOKEN not set');
return [];
}
// TODO: Implement GitHub GraphQL advisory query
// This requires GraphQL API integration with oauth token
// Placeholder for future enhancement
console.warn('GitHub Advisory Database integration not yet implemented');
return [];
}
/**
* Normalize OSV vulnerability data to unified schema.
*
* @param {any} osvVuln - Raw OSV vulnerability object
* @param {string} packageName - Package name
* @param {string} version - Package version
* @returns {import('../lib/types.ts').Vulnerability}
*/
function normalizeOSVVulnerability(osvVuln, packageName, version) {
const id = osvVuln.id || 'UNKNOWN';
const summary = osvVuln.summary || 'No description available';
const details = osvVuln.details || summary;
// Extract severity from database_specific or severity array
let severity = 'info';
if (osvVuln.severity && Array.isArray(osvVuln.severity) && osvVuln.severity.length > 0) {
severity = normalizeSeverity(osvVuln.severity[0].type || 'info');
} else if (osvVuln.database_specific && osvVuln.database_specific.severity) {
severity = normalizeSeverity(osvVuln.database_specific.severity);
}
// Extract references
const references = [];
if (Array.isArray(osvVuln.references)) {
references.push(...osvVuln.references.map((ref) => ref.url).filter(Boolean));
}
// Extract fixed version from affected ranges
let fixedVersion = undefined;
if (Array.isArray(osvVuln.affected)) {
for (const affected of osvVuln.affected) {
if (Array.isArray(affected.ranges)) {
for (const range of affected.ranges) {
if (Array.isArray(range.events)) {
for (const event of range.events) {
if (event.fixed) {
fixedVersion = event.fixed;
break;
}
}
}
}
}
}
}
return {
id,
source: 'osv',
severity,
package: packageName,
version,
fixed_version: fixedVersion,
title: summary,
description: details,
references: uniqueStrings(references),
discovered_at: getTimestamp(),
};
}
/**
* Normalize NVD vulnerability data to unified schema.
*
* @param {any} nvdCve - Raw NVD CVE object
* @returns {import('../lib/types.ts').Vulnerability}
*/
function normalizeNVDVulnerability(nvdCve) {
const id = nvdCve.id || 'UNKNOWN';
// Extract description
let description = 'No description available';
if (nvdCve.descriptions && Array.isArray(nvdCve.descriptions)) {
const englishDesc = nvdCve.descriptions.find((d) => d.lang === 'en');
if (englishDesc && englishDesc.value) {
description = englishDesc.value;
}
}
// Extract severity from CVSS metrics
let severity = 'info';
if (nvdCve.metrics) {
// Try CVSS v3.1 first, then v3.0, then v2.0
const cvssV31 = nvdCve.metrics.cvssMetricV31?.[0];
const cvssV30 = nvdCve.metrics.cvssMetricV30?.[0];
const cvssV2 = nvdCve.metrics.cvssMetricV2?.[0];
const cvssData = cvssV31?.cvssData || cvssV30?.cvssData || cvssV2?.cvssData;
if (cvssData && cvssData.baseSeverity) {
severity = normalizeSeverity(cvssData.baseSeverity);
}
}
// Extract references
const references = [];
if (nvdCve.references && Array.isArray(nvdCve.references)) {
references.push(...nvdCve.references.map((ref) => ref.url).filter(Boolean));
}
return {
id,
source: 'nvd',
severity,
package: 'N/A',
version: '*',
fixed_version: undefined,
title: description.slice(0, 100),
description,
references: uniqueStrings(references),
discovered_at: getTimestamp(),
};
}
/**
* Enrich vulnerability data by querying multiple CVE databases.
* OSV is primary, NVD is fallback for additional details.
*
* @param {string} packageName - Package name
* @param {string} ecosystem - Ecosystem (e.g., 'npm', 'PyPI')
* @param {string} [version] - Optional version
* @returns {Promise<import('../lib/types.ts').Vulnerability[]>}
*/
export async function enrichVulnerability(packageName, ecosystem, version = undefined) {
const results = [];
// Query OSV first (primary source)
const osvResults = await queryOSV(packageName, ecosystem, version);
results.push(...osvResults);
// Optionally query NVD for each CVE ID found in OSV results
const nvdApiKey = process.env.CLAWSEC_NVD_API_KEY;
if (nvdApiKey && results.length > 0) {
for (const vuln of results) {
if (vuln.id.startsWith('CVE-')) {
const nvdData = await queryNVD(vuln.id);
if (nvdData) {
// Merge NVD references into OSV vulnerability
vuln.references = uniqueStrings([...vuln.references, ...nvdData.references]);
}
}
}
}
return results;
}
// CLI entry point for testing
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const packageName = args[0] || 'lodash';
const ecosystem = args[1] || 'npm';
const version = args[2];
console.log(`Querying OSV for ${packageName}@${ecosystem}${version ? ` version ${version}` : ''}...`);
const results = await queryOSV(packageName, ecosystem, version);
console.log(JSON.stringify(results, null, 2));
console.log(`\nFound ${results.length} vulnerabilities`);
}
#!/usr/bin/env bash
set -euo pipefail
# Runner for clawsec-scanner - orchestrates all vulnerability scanning engines.
# - Runs dependency scan (npm audit + pip-audit)
# - Enriches findings with CVE database lookups (OSV, NVD)
# - Runs SAST analysis (Semgrep + Bandit)
# - Runs DAST security tests (hook handler validation)
# - Generates unified vulnerability report
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Default values
TARGET=""
OUTPUT=""
FORMAT="json"
RUN_DEPS=1
RUN_CVE=1
RUN_SAST=1
RUN_DAST=1
# Parse CLI arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
TARGET="${2:-}"
shift 2
;;
--output)
OUTPUT="${2:-}"
shift 2
;;
--format)
FORMAT="${2:-json}"
shift 2
;;
--skip-deps)
RUN_DEPS=0
shift
;;
--skip-cve)
RUN_CVE=0
shift
;;
--skip-sast)
RUN_SAST=0
shift
;;
--skip-dast)
RUN_DAST=0
shift
;;
--help|-h)
cat <<'EOF'
Usage: runner.sh --target <path> [options]
Orchestrates vulnerability scanning across dependency, SAST, DAST, and CVE engines.
Required:
--target <path> Target directory to scan (e.g., ./skills/)
Optional:
--output <file> Write report to file (default: stdout)
--format <json|text> Output format (default: json)
--skip-deps Skip dependency scanning (npm audit, pip-audit)
--skip-cve Skip CVE database enrichment
--skip-sast Skip static analysis (Semgrep, Bandit)
--skip-dast Skip dynamic analysis (hook security tests)
--help, -h Show this help message
Examples:
# Scan all skills with JSON output to file
./runner.sh --target ./skills/ --output report.json
# Scan with human-readable output
./runner.sh --target ./skills/ --format text
# Quick scan: dependencies only
./runner.sh --target ./skills/ --skip-sast --skip-dast --skip-cve
Environment Variables:
CLAWSEC_NVD_API_KEY Optional NVD API key (avoids rate limiting)
GITHUB_TOKEN Optional GitHub token for Advisory Database
CLAWSEC_SCANNER_INTERVAL Hook scan interval in seconds (default: 86400)
CLAWSEC_ALLOW_UNSIGNED_FEED Allow unsigned advisory feed (dev only)
EOF
exit 0
;;
*)
echo "Unknown flag: $1" >&2
echo "Run with --help for usage information" >&2
exit 1
;;
esac
done
# Validate required arguments
if [[ -z "$TARGET" ]]; then
echo "Error: Missing required --target flag" >&2
echo "Run with --help for usage information" >&2
exit 1
fi
# Validate target exists
if [[ ! -e "$TARGET" ]]; then
echo "Error: Target path does not exist: $TARGET" >&2
exit 1
fi
# Validate format
if [[ "$FORMAT" != "json" && "$FORMAT" != "text" ]]; then
echo "Error: Invalid --format value. Use 'json' or 'text'." >&2
exit 1
fi
# Temporary files for intermediate results
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT
DEPS_REPORT="$TEMP_DIR/deps.json"
SAST_REPORT="$TEMP_DIR/sast.json"
DAST_REPORT="$TEMP_DIR/dast.json"
MERGED_REPORT="$TEMP_DIR/merged.json"
# Run dependency scan
if [[ "$RUN_DEPS" -eq 1 ]]; then
if command -v node >/dev/null 2>&1; then
node "$SCRIPT_DIR/scan_dependencies.mjs" --target "$TARGET" --format json > "$DEPS_REPORT" 2>/dev/null || {
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$DEPS_REPORT"
}
else
echo "Warning: node not found, skipping dependency scan" >&2
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$DEPS_REPORT"
fi
else
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$DEPS_REPORT"
fi
# Run SAST analysis
if [[ "$RUN_SAST" -eq 1 ]]; then
if command -v node >/dev/null 2>&1; then
node "$SCRIPT_DIR/sast_analyzer.mjs" --target "$TARGET" --format json > "$SAST_REPORT" 2>/dev/null || {
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$SAST_REPORT"
}
else
echo "Warning: node not found, skipping SAST analysis" >&2
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$SAST_REPORT"
fi
else
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$SAST_REPORT"
fi
# Run DAST tests
if [[ "$RUN_DAST" -eq 1 ]]; then
if command -v node >/dev/null 2>&1; then
if ! node "$SCRIPT_DIR/dast_runner.mjs" --target "$TARGET" --format json > "$DAST_REPORT" 2>/dev/null; then
# dast_runner exits non-zero when high/critical findings exist.
# Preserve a valid JSON report in that case; only fall back to empty on true execution errors.
if [[ -s "$DAST_REPORT" ]] && jq -e '.vulnerabilities and .summary' "$DAST_REPORT" >/dev/null 2>&1; then
echo "Warning: DAST runner exited non-zero; preserving generated findings report" >&2
else
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$DAST_REPORT"
fi
fi
else
echo "Warning: node not found, skipping DAST tests" >&2
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$DAST_REPORT"
fi
else
echo '{"scan_id":"","timestamp":"","target":"","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}' > "$DAST_REPORT"
fi
# Merge reports using jq
if command -v jq >/dev/null 2>&1; then
# Extract vulnerabilities from all reports and merge
jq -s '
{
scan_id: (.[0].scan_id // ""),
timestamp: (.[0].timestamp // (now | todate)),
target: (.[0].target // ""),
vulnerabilities: (map(.vulnerabilities // []) | flatten),
summary: {
critical: (map(.summary.critical // 0) | add),
high: (map(.summary.high // 0) | add),
medium: (map(.summary.medium // 0) | add),
low: (map(.summary.low // 0) | add),
info: (map(.summary.info // 0) | add)
}
}
' "$DEPS_REPORT" "$SAST_REPORT" "$DAST_REPORT" > "$MERGED_REPORT"
else
echo "Error: jq not found. Required for report merging." >&2
exit 1
fi
# CVE enrichment (if enabled and vulnerabilities found)
if [[ "$RUN_CVE" -eq 1 ]]; then
VULN_COUNT=$(jq '.vulnerabilities | length' "$MERGED_REPORT")
if [[ "$VULN_COUNT" -gt 0 ]] && command -v node >/dev/null 2>&1; then
# Note: CVE enrichment is done inline by scan_dependencies.mjs for efficiency
# Future enhancement: implement post-scan enrichment for SAST/DAST findings
:
fi
fi
# Output final report
if [[ "$FORMAT" == "json" ]]; then
FINAL_OUTPUT=$(cat "$MERGED_REPORT")
elif [[ "$FORMAT" == "text" ]]; then
# Convert JSON to human-readable text using Node.js
if command -v node >/dev/null 2>&1; then
FINAL_OUTPUT=$(node -e "
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('$MERGED_REPORT', 'utf8'));
console.log('='.repeat(80));
console.log('ClawSec Vulnerability Scan Report');
console.log('='.repeat(80));
console.log('');
console.log('Scan ID: ' + report.scan_id);
console.log('Target: ' + report.target);
console.log('Timestamp: ' + report.timestamp);
console.log('');
console.log('Summary:');
console.log(' Critical: ' + report.summary.critical);
console.log(' High: ' + report.summary.high);
console.log(' Medium: ' + report.summary.medium);
console.log(' Low: ' + report.summary.low);
console.log(' Info: ' + report.summary.info);
console.log(' Total: ' + report.vulnerabilities.length);
console.log('');
if (report.vulnerabilities.length === 0) {
console.log('✓ No vulnerabilities detected');
console.log('');
} else {
console.log('Vulnerabilities by Severity:');
console.log('');
const bySeverity = {
critical: [],
high: [],
medium: [],
low: [],
info: []
};
report.vulnerabilities.forEach(v => {
const sev = v.severity || 'info';
if (bySeverity[sev]) {
bySeverity[sev].push(v);
}
});
['critical', 'high', 'medium', 'low', 'info'].forEach(severity => {
const vulns = bySeverity[severity];
if (vulns.length > 0) {
console.log(severity.toUpperCase() + ':');
vulns.forEach((v, idx) => {
console.log(' ' + (idx + 1) + '. [' + v.source + '] ' + v.id + ' - ' + v.title);
console.log(' Package: ' + v.package + '@' + v.version);
if (v.fixed_version) {
console.log(' Fix: Upgrade to ' + v.fixed_version);
}
console.log('');
});
}
});
}
console.log('='.repeat(80));
")
else
echo "Error: node required for text format output" >&2
exit 1
fi
else
FINAL_OUTPUT=$(cat "$MERGED_REPORT")
fi
# Write output
if [[ -n "$OUTPUT" ]]; then
printf '%s\n' "$FINAL_OUTPUT" > "$OUTPUT"
else
printf '%s\n' "$FINAL_OUTPUT"
fi
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import {
execCommand,
safeJsonParse,
normalizeSeverity,
getTimestamp,
commandExists,
} from "../lib/utils.mjs";
import { generateReport, formatReportJson, formatReportText } from "../lib/report.mjs";
/**
* @typedef {import('../lib/types.ts').Vulnerability} Vulnerability
* @typedef {import('../lib/types.ts').ScanReport} ScanReport
*/
/**
* Parse CLI arguments.
*
* @param {string[]} argv - Command line arguments
* @returns {{target: string, format: 'json' | 'text'}}
*/
function parseArgs(argv) {
const parsed = {
target: "",
format: "json",
};
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 formatValue = String(argv[i + 1] ?? "").trim();
if (formatValue !== "json" && formatValue !== "text") {
throw new Error("Invalid --format value. Use 'json' or 'text'.");
}
parsed.format = formatValue;
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;
}
/**
* Print usage information.
*/
function printUsage() {
process.stderr.write(
[
"Usage:",
" node scripts/sast_analyzer.mjs --target <path> [--format json|text]",
"",
"Examples:",
" node scripts/sast_analyzer.mjs --target ./skills/clawsec-suite",
" node scripts/sast_analyzer.mjs --target ./skills/ --format json",
"",
"Flags:",
" --target Path to scan (required)",
" --format Output format: json or text (default: json)",
"",
].join("\n"),
);
}
/**
* Check if a file exists.
*
* @param {string} filePath - Path to check
* @returns {Promise<boolean>}
*/
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Run Semgrep for JavaScript/TypeScript analysis.
*
* @param {string} targetPath - Path to scan
* @returns {Promise<Vulnerability[]>}
*/
async function runSemgrep(targetPath) {
const vulnerabilities = [];
// Check if semgrep is available
const hasSemgrep = await commandExists("semgrep");
if (!hasSemgrep) {
process.stderr.write("[semgrep] semgrep command not found, skipping JavaScript/TypeScript SAST\n");
return vulnerabilities;
}
try {
// Run Semgrep with security-focused rules
// NOTE: Semgrep exits non-zero when findings are present
const { stdout } = await execCommand("semgrep", [
"scan",
"--config", "auto",
"--json",
targetPath,
]);
const semgrepData = safeJsonParse(stdout, {
fallback: { results: [] },
label: "semgrep output",
});
// Semgrep format: { results: [ {check_id, path, extra: {message, severity, ...}, ...} ] }
if (semgrepData && typeof semgrepData === "object" && "results" in semgrepData) {
const results = Array.isArray(semgrepData.results) ? semgrepData.results : [];
for (const result of results) {
if (!result || typeof result !== "object") continue;
const checkId = String(result.check_id || "semgrep-unknown");
const filePath = String(result.path || "unknown");
const extra = result.extra || {};
// Extract metadata
const message = String(extra.message || "Security issue detected");
const severity = normalizeSeverity(extra.severity || "info");
const metadata = extra.metadata || {};
// Build references from metadata
const references = [];
if (metadata.references && Array.isArray(metadata.references)) {
references.push(...metadata.references.map((r) => String(r)));
}
if (metadata.source && typeof metadata.source === "string") {
references.push(metadata.source);
}
const vuln = {
id: checkId,
source: "sast",
severity,
package: path.basename(filePath),
version: `${filePath}:${result.start?.line || 0}`,
fixed_version: "",
title: message.slice(0, 150),
description: message,
references,
discovered_at: getTimestamp(),
};
vulnerabilities.push(vuln);
}
}
} catch (error) {
if (error instanceof Error) {
process.stderr.write(`[semgrep] Warning: ${error.message}\n`);
}
// Continue with partial results
}
return vulnerabilities;
}
/**
* Run Bandit for Python analysis.
*
* @param {string} targetPath - Path to scan
* @returns {Promise<Vulnerability[]>}
*/
async function runBandit(targetPath) {
const vulnerabilities = [];
// Check if bandit is available
const hasBandit = await commandExists("bandit");
if (!hasBandit) {
process.stderr.write("[bandit] bandit command not found, skipping Python SAST\n");
return vulnerabilities;
}
// Check if pyproject.toml exists in the project root
const pyprojectPath = path.join(process.cwd(), "pyproject.toml");
const hasPyproject = await fileExists(pyprojectPath);
try {
// Run Bandit with JSON output
// NOTE: Bandit exits non-zero when findings are present
const args = ["-r", targetPath, "-f", "json"];
// Only add -c flag if pyproject.toml exists
if (hasPyproject) {
args.push("-c", pyprojectPath);
}
const { stdout } = await execCommand("bandit", args);
const banditData = safeJsonParse(stdout, {
fallback: { results: [] },
label: "bandit output",
});
// Bandit format: { results: [ {issue_text, issue_severity, issue_confidence, test_id, filename, line_number, ...} ] }
if (banditData && typeof banditData === "object" && "results" in banditData) {
const results = Array.isArray(banditData.results) ? banditData.results : [];
for (const result of results) {
if (!result || typeof result !== "object") continue;
const testId = String(result.test_id || "bandit-unknown");
const filePath = String(result.filename || "unknown");
const lineNumber = result.line_number || 0;
const issueText = String(result.issue_text || "Security issue detected");
const issueSeverity = String(result.issue_severity || "LOW");
// Map Bandit severity (HIGH, MEDIUM, LOW) to normalized severity
const severity = normalizeSeverity(issueSeverity);
const vuln = {
id: testId,
source: "sast",
severity,
package: path.basename(filePath),
version: `${filePath}:${lineNumber}`,
fixed_version: "",
title: issueText.slice(0, 150),
description: issueText,
references: [
`https://bandit.readthedocs.io/en/latest/plugins/${testId.toLowerCase().replace(/_/g, '-')}.html`,
],
discovered_at: getTimestamp(),
};
vulnerabilities.push(vuln);
}
}
} catch (error) {
if (error instanceof Error) {
process.stderr.write(`[bandit] Warning: ${error.message}\n`);
}
// Continue with partial results
}
return vulnerabilities;
}
/**
* Main entry point.
*/
async function main() {
try {
const args = parseArgs(process.argv.slice(2));
// Verify target path exists
const targetExists = await fileExists(args.target);
if (!targetExists) {
throw new Error(`Target path does not exist: ${args.target}`);
}
// Run SAST tools
const semgrepVulns = await runSemgrep(args.target);
const banditVulns = await runBandit(args.target);
// Combine all vulnerabilities
const allVulnerabilities = [...semgrepVulns, ...banditVulns];
// Generate unified report
const report = generateReport(allVulnerabilities, args.target);
// Output report
if (args.format === "json") {
process.stdout.write(formatReportJson(report));
process.stdout.write("\n");
} else {
process.stdout.write(formatReportText(report));
}
// Exit 0 even if vulnerabilities found (advisory only)
process.exit(0);
} catch (error) {
if (error instanceof Error) {
process.stderr.write(`Error: ${error.message}\n`);
}
process.exit(1);
}
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import {
execCommand,
safeJsonParse,
normalizeSeverity,
getTimestamp,
commandExists,
} from "../lib/utils.mjs";
import { generateReport, formatReportJson, formatReportText } from "../lib/report.mjs";
/**
* @typedef {import('../lib/types.ts').Vulnerability} Vulnerability
* @typedef {import('../lib/types.ts').ScanReport} ScanReport
*/
/**
* Parse CLI arguments.
*
* @param {string[]} argv - Command line arguments
* @returns {{target: string, format: 'json' | 'text'}}
*/
function parseArgs(argv) {
const parsed = {
target: "",
format: "json",
};
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 formatValue = String(argv[i + 1] ?? "").trim();
if (formatValue !== "json" && formatValue !== "text") {
throw new Error("Invalid --format value. Use 'json' or 'text'.");
}
parsed.format = formatValue;
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;
}
/**
* Print usage information.
*/
function printUsage() {
process.stderr.write(
[
"Usage:",
" node scripts/scan_dependencies.mjs --target <path> [--format json|text]",
"",
"Examples:",
" node scripts/scan_dependencies.mjs --target ./skills/clawsec-suite",
" node scripts/scan_dependencies.mjs --target ./skills/ --format json",
"",
"Flags:",
" --target Path to scan (required)",
" --format Output format: json or text (default: json)",
"",
].join("\n"),
);
}
/**
* Check if a file exists.
*
* @param {string} filePath - Path to check
* @returns {Promise<boolean>}
*/
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Run npm audit and parse vulnerabilities.
*
* @param {string} targetPath - Path to scan
* @returns {Promise<Vulnerability[]>}
*/
async function scanNpmAudit(targetPath) {
const vulnerabilities = [];
// Check if package-lock.json exists
const packageLockPath = path.join(targetPath, "package-lock.json");
const hasPackageLock = await fileExists(packageLockPath);
if (!hasPackageLock) {
process.stderr.write(`[npm-audit] No package-lock.json found in ${targetPath}, skipping npm audit\n`);
return vulnerabilities;
}
// Check if npm is available
const hasNpm = await commandExists("npm");
if (!hasNpm) {
process.stderr.write("[npm-audit] npm command not found, skipping npm audit\n");
return vulnerabilities;
}
try {
// Run npm audit with JSON output
// NOTE: npm audit exits non-zero when vulnerabilities are found
const { stdout } = await execCommand("npm", ["audit", "--json"], { cwd: targetPath });
const auditData = safeJsonParse(stdout, {
fallback: { vulnerabilities: {} },
label: "npm audit output",
});
// npm audit v7+ format: { vulnerabilities: { [package]: {...} } }
if (auditData && typeof auditData === "object" && "vulnerabilities" in auditData) {
const vulnsMap = auditData.vulnerabilities;
if (vulnsMap && typeof vulnsMap === "object") {
for (const [packageName, vulnData] of Object.entries(vulnsMap)) {
if (!vulnData || typeof vulnData !== "object") continue;
// Extract vulnerability data
const severity = normalizeSeverity(vulnData.severity || "info");
const version = String(vulnData.range || vulnData.version || "unknown");
const via = Array.isArray(vulnData.via) ? vulnData.via : [];
// npm audit can have multiple advisories via the 'via' field
for (const viaItem of via) {
if (typeof viaItem === "object" && viaItem !== null) {
const vuln = {
id: String(viaItem.source || viaItem.cve || `npm-${packageName}`),
source: "npm-audit",
severity,
package: packageName,
version,
fixed_version: String(vulnData.fixAvailable?.version || ""),
title: String(viaItem.title || `Vulnerability in ${packageName}`),
description: String(viaItem.title || viaItem.name || "No description available"),
references: viaItem.url ? [String(viaItem.url)] : [],
discovered_at: getTimestamp(),
};
vulnerabilities.push(vuln);
}
}
// If 'via' doesn't have objects, create a generic entry
if (via.length === 0 || via.every((v) => typeof v !== "object")) {
const vuln = {
id: `npm-${packageName}`,
source: "npm-audit",
severity,
package: packageName,
version,
fixed_version: String(vulnData.fixAvailable?.version || ""),
title: `Vulnerability in ${packageName}`,
description: String(vulnData.name || `Vulnerability detected in ${packageName}`),
references: [],
discovered_at: getTimestamp(),
};
vulnerabilities.push(vuln);
}
}
}
}
} catch (error) {
if (error instanceof Error) {
process.stderr.write(`[npm-audit] Warning: ${error.message}\n`);
}
// Continue with partial results
}
return vulnerabilities;
}
/**
* Run pip-audit and parse vulnerabilities.
*
* @param {string} targetPath - Path to scan
* @returns {Promise<Vulnerability[]>}
*/
async function scanPipAudit(targetPath) {
const vulnerabilities = [];
// Check if pip-audit is available
const hasPipAudit = await commandExists("pip-audit");
if (!hasPipAudit) {
process.stderr.write("[pip-audit] pip-audit command not found, skipping Python dependency scan\n");
return vulnerabilities;
}
// Check if requirements.txt or setup.py exists
const requirementsTxt = path.join(targetPath, "requirements.txt");
const setupPy = path.join(targetPath, "setup.py");
const pyprojectToml = path.join(targetPath, "pyproject.toml");
const hasRequirements = await fileExists(requirementsTxt);
const hasSetupPy = await fileExists(setupPy);
const hasPyprojectToml = await fileExists(pyprojectToml);
if (!hasRequirements && !hasSetupPy && !hasPyprojectToml) {
process.stderr.write(
`[pip-audit] No Python dependency files found in ${targetPath}, skipping pip-audit\n`,
);
return vulnerabilities;
}
try {
// Prefer requirements.txt when present; otherwise scan project context in target dir.
const pipAuditArgs = hasRequirements ? ["-f", "json", "-r", "requirements.txt"] : ["-f", "json"];
const { stdout } = await execCommand("pip-audit", pipAuditArgs, { cwd: targetPath });
const auditData = safeJsonParse(stdout, {
fallback: { dependencies: [] },
label: "pip-audit output",
});
// pip-audit format: { dependencies: [ {name, version, vulns: [{id, fix_versions, description, ...}]} ] }
if (auditData && typeof auditData === "object" && "dependencies" in auditData) {
const deps = Array.isArray(auditData.dependencies) ? auditData.dependencies : [];
for (const dep of deps) {
if (!dep || typeof dep !== "object") continue;
const packageName = String(dep.name || "unknown");
const version = String(dep.version || "unknown");
const vulns = Array.isArray(dep.vulns) ? dep.vulns : [];
for (const vulnData of vulns) {
if (!vulnData || typeof vulnData !== "object") continue;
const fixVersions = Array.isArray(vulnData.fix_versions) ? vulnData.fix_versions : [];
const vuln = {
id: String(vulnData.id || `pip-${packageName}`),
source: "pip-audit",
severity: normalizeSeverity(vulnData.severity || "info"),
package: packageName,
version,
fixed_version: fixVersions.length > 0 ? String(fixVersions[0]) : "",
title: String(vulnData.description || `Vulnerability in ${packageName}`).slice(0, 150),
description: String(vulnData.description || "No description available"),
references: vulnData.link ? [String(vulnData.link)] : [],
discovered_at: getTimestamp(),
};
vulnerabilities.push(vuln);
}
}
}
} catch (error) {
if (error instanceof Error) {
process.stderr.write(`[pip-audit] Warning: ${error.message}\n`);
}
// Continue with partial results
}
return vulnerabilities;
}
/**
* Main entry point.
*/
async function main() {
try {
const args = parseArgs(process.argv.slice(2));
// Verify target path exists
const targetExists = await fileExists(args.target);
if (!targetExists) {
throw new Error(`Target path does not exist: ${args.target}`);
}
// Run dependency scanners
const npmVulns = await scanNpmAudit(args.target);
const pipVulns = await scanPipAudit(args.target);
// Combine all vulnerabilities
const allVulnerabilities = [...npmVulns, ...pipVulns];
// Generate unified report
const report = generateReport(allVulnerabilities, args.target);
// Output report
if (args.format === "json") {
process.stdout.write(formatReportJson(report));
process.stdout.write("\n");
} else {
process.stdout.write(formatReportText(report));
}
// Exit 0 even if vulnerabilities found (advisory only)
process.exit(0);
} catch (error) {
if (error instanceof Error) {
process.stderr.write(`Error: ${error.message}\n`);
}
process.exit(1);
}
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HOOK_NAME = "clawsec-scanner-hook";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const SCANNER_DIR = path.resolve(SCRIPT_DIR, "..");
const SOURCE_HOOK_DIR = path.join(SCANNER_DIR, "hooks", HOOK_NAME);
const HOOKS_ROOT = path.join(os.homedir(), ".openclaw", "hooks");
const TARGET_HOOK_DIR = path.join(HOOKS_ROOT, HOOK_NAME);
function sh(cmd, args) {
const result = spawnSync(cmd, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const details = (result.stderr || result.stdout || "").trim();
throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`);
}
return result.stdout;
}
function requireOpenClawCli() {
try {
sh("openclaw", ["--version"]);
} catch (error) {
throw new Error(
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
`Original error: ${String(error)}`,
{ cause: error },
);
}
}
function assertSourceHookExists() {
const requiredFiles = [
"HOOK.md",
"handler.ts",
];
for (const file of requiredFiles) {
const fullPath = path.join(SOURCE_HOOK_DIR, file);
if (!fs.existsSync(fullPath)) {
throw new Error(`Missing required hook file: ${fullPath}`);
}
}
// Verify lib files exist in parent skill directory
const requiredLibFiles = [
"lib/utils.mjs",
"lib/report.mjs",
"lib/types.ts",
];
for (const file of requiredLibFiles) {
const fullPath = path.join(SCANNER_DIR, file);
if (!fs.existsSync(fullPath)) {
throw new Error(`Missing required lib file: ${fullPath}`);
}
}
// Verify scanner scripts exist
const requiredScripts = [
"scripts/runner.sh",
"scripts/scan_dependencies.mjs",
"scripts/sast_analyzer.mjs",
"scripts/dast_runner.mjs",
"scripts/dast_hook_executor.mjs",
"scripts/query_cve_databases.mjs",
];
for (const file of requiredScripts) {
const fullPath = path.join(SCANNER_DIR, file);
if (!fs.existsSync(fullPath)) {
throw new Error(`Missing required scanner script: ${fullPath}`);
}
}
}
function installHookFiles() {
fs.mkdirSync(HOOKS_ROOT, { recursive: true });
fs.rmSync(TARGET_HOOK_DIR, { recursive: true, force: true });
fs.cpSync(SOURCE_HOOK_DIR, TARGET_HOOK_DIR, { recursive: true });
// Copy lib files to hook directory
const targetLibDir = path.join(TARGET_HOOK_DIR, "lib");
const sourceLibDir = path.join(SCANNER_DIR, "lib");
fs.mkdirSync(targetLibDir, { recursive: true });
fs.cpSync(sourceLibDir, targetLibDir, { recursive: true });
// Copy scanner scripts to hook directory
const targetScriptsDir = path.join(TARGET_HOOK_DIR, "scripts");
const sourceScriptsDir = path.join(SCANNER_DIR, "scripts");
fs.mkdirSync(targetScriptsDir, { recursive: true });
fs.cpSync(sourceScriptsDir, targetScriptsDir, { recursive: true });
}
function enableHook() {
sh("openclaw", ["hooks", "enable", HOOK_NAME]);
}
function main() {
assertSourceHookExists();
requireOpenClawCli();
installHookFiles();
enableHook();
process.stdout.write(`Installed hook files to: ${TARGET_HOOK_DIR}\n`);
process.stdout.write(`Enabled hook: ${HOOK_NAME}\n`);
process.stdout.write("Restart your OpenClaw gateway process so the hook is loaded.\n");
process.stdout.write("After restart, run /new once to trigger an immediate vulnerability scan.\n");
}
try {
main();
} catch (error) {
process.stderr.write(`${String(error)}\n`);
process.exit(1);
}
{
"name": "clawsec-scanner",
"version": "0.0.5",
"description": "Automated vulnerability scanner for agent platforms. Performs dependency scanning (npm audit, pip-audit), multi-database CVE lookup (OSV, NVD, GitHub Advisory), SAST analysis (Semgrep, Bandit), and agent-specific static hook inspection for OpenClaw hooks.",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
"homepage": "https://clawsec.prompt.security/",
"keywords": [
"security",
"vulnerability",
"scanner",
"dependency",
"cve",
"sast",
"dast",
"audit",
"agents",
"ai",
"openclaw",
"semgrep",
"bandit",
"osv",
"nvd"
],
"sbom": {
"files": [
{
"path": "SKILL.md",
"required": true,
"description": "Scanner skill documentation and usage guide"
},
{
"path": "CHANGELOG.md",
"required": true,
"description": "Version history and feature changelog"
},
{
"path": "scripts/runner.sh",
"required": true,
"description": "Main orchestration script for running all scanner engines"
},
{
"path": "scripts/scan_dependencies.mjs",
"required": true,
"description": "Dependency scanner using npm audit and pip-audit with JSON parsing"
},
{
"path": "scripts/query_cve_databases.mjs",
"required": true,
"description": "Multi-database CVE lookup (OSV primary, NVD/GitHub fallback)"
},
{
"path": "scripts/sast_analyzer.mjs",
"required": true,
"description": "Static analysis engine running Semgrep and Bandit as subprocesses"
},
{
"path": "scripts/dast_runner.mjs",
"required": true,
"description": "Static OpenClaw hook inspection harness that does not execute target handlers"
},
{
"path": "scripts/dast_hook_executor.mjs",
"required": true,
"description": "Static hook source inspection helper used by DAST without importing target handlers"
},
{
"path": "scripts/setup_scanner_hook.mjs",
"required": false,
"description": "Hook installer for continuous monitoring integration"
},
{
"path": "lib/report.mjs",
"required": true,
"description": "Unified vulnerability report generator (JSON and human-readable formats)"
},
{
"path": "lib/utils.mjs",
"required": true,
"description": "Shared utility functions for subprocess execution and JSON parsing"
},
{
"path": "lib/types.ts",
"required": true,
"description": "TypeScript type definitions for Vulnerability and ScanReport schemas"
},
{
"path": "hooks/clawsec-scanner-hook/HOOK.md",
"required": false,
"description": "OpenClaw hook metadata for continuous scanning integration"
},
{
"path": "hooks/clawsec-scanner-hook/handler.ts",
"required": false,
"description": "OpenClaw hook handler for periodic vulnerability scanning"
}
]
},
"openclaw": {
"emoji": "🔍",
"category": "security",
"requires": {
"bins": [
"node",
"npm",
"python3",
"pip-audit",
"semgrep",
"bandit",
"jq",
"curl"
]
},
"triggers": [
"vulnerability scan",
"security scan",
"dependency scan",
"cve scan",
"sast scan",
"run scanner",
"scan vulnerabilities",
"check vulnerabilities",
"audit dependencies",
"security check"
]
}
}
#!/usr/bin/env node
/**
* CVE integration tests for clawsec-scanner.
*
* Tests cover:
* - OSV API query and normalization
* - NVD API query and normalization
* - GitHub Advisory Database query (placeholder)
* - Multi-source enrichment
* - Error handling and timeouts
* - Rate limiting behavior
*
* Run: node skills/clawsec-scanner/test/cve_integration.test.mjs
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import { pass, fail, report, exitWithResults, withEnv } from "./lib/test_harness.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPTS_PATH = path.resolve(__dirname, "..", "scripts");
// Dynamic import to ensure we test the actual modules
const { queryOSV, queryNVD, queryGitHub, enrichVulnerability } = await import(
`${SCRIPTS_PATH}/query_cve_databases.mjs`
);
// -----------------------------------------------------------------------------
// Test: queryOSV - successful query with results
// -----------------------------------------------------------------------------
async function testQueryOSV_Success() {
const testName = "queryOSV: successful query returns vulnerabilities";
try {
// Query a known vulnerable package (lodash has known vulnerabilities)
const results = await queryOSV("lodash", "npm", "4.17.19");
// lodash 4.17.19 has known vulnerabilities
if (Array.isArray(results) && results.length > 0) {
// Verify structure of first result
const vuln = results[0];
if (
vuln.id &&
vuln.source === "osv" &&
vuln.severity &&
vuln.package === "lodash" &&
vuln.title &&
vuln.description &&
Array.isArray(vuln.references)
) {
pass(testName);
} else {
fail(testName, `Invalid vulnerability structure: ${JSON.stringify(vuln)}`);
}
} else {
// If no results, package may have been patched - that's also valid
pass(testName);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryOSV - returns empty array for non-existent package
// -----------------------------------------------------------------------------
async function testQueryOSV_NotFound() {
const testName = "queryOSV: returns empty array for non-existent package";
try {
const results = await queryOSV("nonexistent-package-that-does-not-exist-12345", "npm");
if (Array.isArray(results) && results.length === 0) {
pass(testName);
} else {
fail(testName, `Expected empty array, got ${results.length} results`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryOSV - handles network errors gracefully
// -----------------------------------------------------------------------------
async function testQueryOSV_NetworkError() {
const testName = "queryOSV: handles network errors gracefully";
try {
// This will likely timeout or fail, but should return empty array
const results = await queryOSV("test-pkg", "invalid-ecosystem-999");
if (Array.isArray(results)) {
pass(testName);
} else {
fail(testName, `Expected array, got ${typeof results}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryOSV - version-specific query
// -----------------------------------------------------------------------------
async function testQueryOSV_WithVersion() {
const testName = "queryOSV: handles version-specific queries";
try {
const results = await queryOSV("express", "npm", "4.16.0");
// Express 4.16.0 may or may not have vulnerabilities
// Just verify it returns an array
if (Array.isArray(results)) {
pass(testName);
} else {
fail(testName, `Expected array, got ${typeof results}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryOSV - normalizes severity correctly
// -----------------------------------------------------------------------------
async function testQueryOSV_SeverityNormalization() {
const testName = "queryOSV: normalizes severity from API response";
try {
const results = await queryOSV("lodash", "npm", "4.17.19");
if (results.length > 0) {
const validSeverities = ["critical", "high", "medium", "low", "info"];
const allValid = results.every((vuln) => validSeverities.includes(vuln.severity));
if (allValid) {
pass(testName);
} else {
fail(
testName,
`Invalid severity found: ${results.map((v) => v.severity).join(", ")}`,
);
}
} else {
// No results is valid
pass(testName);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryNVD - requires API key or respects rate limiting
// -----------------------------------------------------------------------------
async function testQueryNVD_RateLimiting() {
const testName = "queryNVD: respects rate limiting without API key";
try {
await withEnv("CLAWSEC_NVD_API_KEY", undefined, async () => {
const startTime = Date.now();
// Query should add 6-second delay when no API key (if request succeeds)
await queryNVD("CVE-2021-44228");
const elapsed = Date.now() - startTime;
// If the request failed quickly (network issue), skip the test
if (elapsed < 100) {
pass(testName + " (skipped - network unavailable)");
} else if (elapsed >= 5900) {
// Should take at least 6 seconds if successful
pass(testName);
} else {
fail(testName, `Expected ~6s delay, got ${elapsed}ms`);
}
});
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryNVD - handles non-existent CVE
// -----------------------------------------------------------------------------
async function testQueryNVD_NotFound() {
const testName = "queryNVD: returns null for non-existent CVE";
try {
await withEnv("CLAWSEC_NVD_API_KEY", undefined, async () => {
const result = await queryNVD("CVE-9999-99999");
if (result === null) {
pass(testName);
} else {
fail(testName, `Expected null, got ${JSON.stringify(result)}`);
}
});
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryNVD - valid CVE returns structured data
// -----------------------------------------------------------------------------
async function testQueryNVD_ValidCVE() {
const testName = "queryNVD: valid CVE returns structured vulnerability";
try {
// Only run if API key is set (to avoid rate limiting in CI)
const apiKey = process.env.CLAWSEC_NVD_API_KEY;
if (!apiKey) {
pass(testName + " (skipped - no API key)");
return;
}
const result = await queryNVD("CVE-2021-44228");
if (result && result.id === "CVE-2021-44228" && result.source === "nvd") {
pass(testName);
} else if (result === null) {
// API might be down or rate limited
pass(testName + " (API returned null)");
} else {
fail(testName, `Unexpected result: ${JSON.stringify(result)}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryGitHub - returns empty array when token not set
// -----------------------------------------------------------------------------
async function testQueryGitHub_NoToken() {
const testName = "queryGitHub: returns empty array when token not set";
try {
await withEnv("GITHUB_TOKEN", undefined, async () => {
const results = await queryGitHub("test-package", "npm");
if (Array.isArray(results) && results.length === 0) {
pass(testName);
} else {
fail(testName, `Expected empty array, got ${results.length} results`);
}
});
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: queryGitHub - placeholder implementation
// -----------------------------------------------------------------------------
async function testQueryGitHub_Placeholder() {
const testName = "queryGitHub: placeholder returns empty array with token";
try {
await withEnv("GITHUB_TOKEN", "fake-token-for-testing", async () => {
const results = await queryGitHub("test-package", "npm");
// Current implementation is a placeholder
if (Array.isArray(results) && results.length === 0) {
pass(testName);
} else {
fail(testName, `Expected empty array, got ${results.length} results`);
}
});
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: enrichVulnerability - combines OSV results
// -----------------------------------------------------------------------------
async function testEnrichVulnerability_OSVOnly() {
const testName = "enrichVulnerability: returns OSV results";
try {
await withEnv("CLAWSEC_NVD_API_KEY", undefined, async () => {
const results = await enrichVulnerability("lodash", "npm", "4.17.19");
if (Array.isArray(results)) {
pass(testName);
} else {
fail(testName, `Expected array, got ${typeof results}`);
}
});
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: enrichVulnerability - enriches with NVD when API key present
// -----------------------------------------------------------------------------
async function testEnrichVulnerability_WithNVD() {
const testName = "enrichVulnerability: enriches with NVD when API key present";
try {
const apiKey = process.env.CLAWSEC_NVD_API_KEY;
if (!apiKey) {
pass(testName + " (skipped - no API key)");
return;
}
// Query a package with known CVE
const results = await enrichVulnerability("lodash", "npm", "4.17.19");
// If results contain CVE IDs, they should have enriched references
const hasCVE = results.some((v) => v.id.startsWith("CVE-"));
if (hasCVE) {
// Check if references were enriched (should have more than original OSV refs)
const hasReferences = results.some((v) => v.references.length > 0);
if (hasReferences) {
pass(testName);
} else {
fail(testName, "Expected enriched references from NVD");
}
} else {
// No CVEs found, which is valid
pass(testName + " (no CVEs to enrich)");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: enrichVulnerability - handles empty results
// -----------------------------------------------------------------------------
async function testEnrichVulnerability_Empty() {
const testName = "enrichVulnerability: handles packages with no vulnerabilities";
try {
const results = await enrichVulnerability(
"nonexistent-package-12345",
"npm",
"1.0.0",
);
if (Array.isArray(results) && results.length === 0) {
pass(testName);
} else {
fail(testName, `Expected empty array, got ${results.length} results`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: OSV normalization - extracts severity
// -----------------------------------------------------------------------------
async function testOSVNormalization_Severity() {
const testName = "OSV normalization: extracts severity correctly";
try {
// Query real data and check normalization
const results = await queryOSV("lodash", "npm", "4.17.19");
if (results.length > 0) {
const vuln = results[0];
const validSeverities = ["critical", "high", "medium", "low", "info"];
if (validSeverities.includes(vuln.severity)) {
pass(testName);
} else {
fail(testName, `Invalid severity: ${vuln.severity}`);
}
} else {
pass(testName + " (no results to test)");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: OSV normalization - extracts references
// -----------------------------------------------------------------------------
async function testOSVNormalization_References() {
const testName = "OSV normalization: extracts references";
try {
const results = await queryOSV("lodash", "npm", "4.17.19");
if (results.length > 0) {
const vuln = results[0];
if (Array.isArray(vuln.references)) {
// References should be URLs
const allUrls = vuln.references.every((ref) => ref.startsWith("http"));
if (allUrls) {
pass(testName);
} else {
fail(testName, `Non-URL reference found: ${vuln.references.join(", ")}`);
}
} else {
fail(testName, "References is not an array");
}
} else {
pass(testName + " (no results to test)");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: OSV normalization - extracts fixed version
// -----------------------------------------------------------------------------
async function testOSVNormalization_FixedVersion() {
const testName = "OSV normalization: extracts fixed version";
try {
const results = await queryOSV("lodash", "npm", "4.17.19");
if (results.length > 0) {
const hasFixedVersion = results.some((v) => v.fixed_version !== undefined);
if (hasFixedVersion) {
pass(testName);
} else {
// Some vulnerabilities may not have a fixed version yet
pass(testName + " (no fixed versions available)");
}
} else {
pass(testName + " (no results to test)");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: OSV normalization - includes timestamp
// -----------------------------------------------------------------------------
async function testOSVNormalization_Timestamp() {
const testName = "OSV normalization: includes discovery timestamp";
try {
const results = await queryOSV("lodash", "npm", "4.17.19");
if (results.length > 0) {
const vuln = results[0];
const iso8601Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
if (vuln.discovered_at && iso8601Pattern.test(vuln.discovered_at)) {
pass(testName);
} else {
fail(testName, `Invalid timestamp: ${vuln.discovered_at}`);
}
} else {
pass(testName + " (no results to test)");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Vulnerability structure - required fields present
// -----------------------------------------------------------------------------
async function testVulnerabilityStructure() {
const testName = "Vulnerability structure: has all required fields";
try {
const results = await queryOSV("lodash", "npm", "4.17.19");
if (results.length > 0) {
const vuln = results[0];
const hasAllFields =
"id" in vuln &&
"source" in vuln &&
"severity" in vuln &&
"package" in vuln &&
"version" in vuln &&
"title" in vuln &&
"description" in vuln &&
"references" in vuln &&
"discovered_at" in vuln;
if (hasAllFields) {
pass(testName);
} else {
fail(testName, `Missing required fields: ${JSON.stringify(vuln)}`);
}
} else {
pass(testName + " (no results to test)");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Multiple ecosystems - PyPI support
// -----------------------------------------------------------------------------
async function testMultipleEcosystems_PyPI() {
const testName = "Multiple ecosystems: PyPI packages";
try {
// Query a known vulnerable Python package
const results = await queryOSV("requests", "PyPI", "2.6.0");
// Verify it returns valid results
if (Array.isArray(results)) {
pass(testName);
} else {
fail(testName, `Expected array, got ${typeof results}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Multiple ecosystems - npm support
// -----------------------------------------------------------------------------
async function testMultipleEcosystems_npm() {
const testName = "Multiple ecosystems: npm packages";
try {
const results = await queryOSV("express", "npm");
if (Array.isArray(results)) {
pass(testName);
} else {
fail(testName, `Expected array, got ${typeof results}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
async function main() {
console.log("Running CVE integration tests...\n");
// OSV API tests
await testQueryOSV_Success();
await testQueryOSV_NotFound();
await testQueryOSV_NetworkError();
await testQueryOSV_WithVersion();
await testQueryOSV_SeverityNormalization();
// NVD API tests
await testQueryNVD_RateLimiting();
await testQueryNVD_NotFound();
await testQueryNVD_ValidCVE();
// GitHub Advisory tests
await testQueryGitHub_NoToken();
await testQueryGitHub_Placeholder();
// Enrichment tests
await testEnrichVulnerability_OSVOnly();
await testEnrichVulnerability_WithNVD();
await testEnrichVulnerability_Empty();
// Normalization tests
await testOSVNormalization_Severity();
await testOSVNormalization_References();
await testOSVNormalization_FixedVersion();
await testOSVNormalization_Timestamp();
// Structure tests
await testVulnerabilityStructure();
// Ecosystem tests
await testMultipleEcosystems_PyPI();
await testMultipleEcosystems_npm();
// Final report
report();
exitWithResults();
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
/**
* Shared test harness for clawsec-scanner tests.
* Provides consistent test reporting and runner utilities.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
let passCount = 0;
let failCount = 0;
/**
* Records a passing test.
* @param {string} name - Test name
*/
export function pass(name) {
passCount++;
console.log(`✓ ${name}`);
}
/**
* Records a failing test.
* @param {string} name - Test name
* @param {Error|string} error - Error details
*/
export function fail(name, error) {
failCount++;
console.error(`✗ ${name}`);
console.error(` ${String(error)}`);
}
/**
* Gets current test statistics.
* @returns {{passCount: number, failCount: number}}
*/
export function getStats() {
return { passCount, failCount };
}
/**
* Reports final test results to console.
*/
export function report() {
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
}
/**
* Exits with appropriate code based on test results.
* Exit code 0 for success, 1 for failures.
*/
export function exitWithResults() {
if (failCount > 0) {
process.exit(1);
}
}
/**
* Creates a temporary directory for test use.
* @returns {Promise<{path: string, cleanup: Function}>} Object with temp dir path and cleanup function
*/
export async function createTempDir() {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-scanner-test-"));
return {
path: tmpDir,
cleanup: async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
},
};
}
/**
* Temporarily sets an environment variable for the duration of a function.
* Restores the original value (or deletes the variable) after the function completes.
* @param {string} key - Environment variable name
* @param {string|undefined} value - Value to set (undefined to delete)
* @param {Function} fn - Function to execute with the modified environment
* @returns {Promise<*>} Result of the function
*/
export async function withEnv(key, value, fn) {
const oldValue = process.env[key];
try {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
return await fn();
} finally {
if (oldValue === undefined) {
delete process.env[key];
} else {
process.env[key] = oldValue;
}
}
}