Files
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

292 lines
8.7 KiB
JavaScript

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`);
}