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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verification passed: JSON output with valid vulnerabilities array

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

326 lines
9.9 KiB
JavaScript
Executable File

#!/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();
}