Compare commits

...

11 Commits

Author SHA1 Message Date
davida-ps f0f0f1db97 fix(clawsec-scanner): release 0.0.2 with real OpenClaw DAST harness (#128)
* fix(clawsec-scanner): ship real openclaw dast harness in 0.0.2

* fix(clawsec-scanner): classify ts harness limits as info coverage

* docs(wiki): add clawsec-scanner module documentation

* docs(release): add clawsec-suite install guidance to quick install text

* docs(readme): clarify standalone installs and suite optionality

* docs(readme): remove standalone quick-install block

* docs(readme): rename skill section and clarify suite start point
2026-03-10 19:27:22 +02:00
dependabot[bot] 687822b6cb chore(deps-dev): bump typescript from 5.8.3 to 5.9.3 (#109)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.8.3 to 5.9.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 5.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 17:10:33 +02:00
dependabot[bot] e715c8a625 chore(deps): bump actions/setup-node from 6.2.0 to 6.3.0 (#120)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/6044e13b5dc448c55e2357c09f80417699197238...53b83947a5a98c8d113130e565377fae1a50d02f)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 16:51:09 +02:00
dependabot[bot] bd54393ed4 chore(deps-dev): bump @types/node from 25.2.3 to 25.4.0 (#125)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.2.3 to 25.4.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 13:59:08 +02:00
dependabot[bot] 0fcc6e6b6d chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (#107)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 13:55:23 +02:00
dependabot[bot] 8d292457fb chore(deps): bump bandit from 1.9.3 to 1.9.4 in /.github (#103)
Bumps [bandit](https://github.com/PyCQA/bandit) from 1.9.3 to 1.9.4.
- [Release notes](https://github.com/PyCQA/bandit/releases)
- [Commits](https://github.com/PyCQA/bandit/compare/1.9.3...1.9.4)

---
updated-dependencies:
- dependency-name: bandit
  dependency-version: 1.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 13:52:00 +02:00
github-actions[bot] 1cced651a0 chore: CVE advisories - 0 new, 20 updated (#127)
Automated update from NVD CVE feed.
Keywords: OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys
Poll window: 2026-03-09T06:19:31Z to 2026-03-10T06:12:16.000Z

Co-authored-by: davida-ps <232346510+davida-ps@users.noreply.github.com>
2026-03-10 09:07:06 +02:00
davida-ps 83ce1d0bf5 fix(release): enforce changelog match for tagged skill releases (#118) 2026-03-09 21:30:52 +02:00
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
davida-ps 81c2e60513 fix(ci): temporary clawhub publish workaround for MIT-0 consent (#117)
* fix(ci): patch clawhub publish payload for temporary MIT-0 consent workaround

* fix(ci): make clawhub publish patch self-contained for tag republish

* fix(clawsec-nanoclaw): harden signature verification boundaries

* chore(clawsec-nanoclaw): bump version to 0.0.3

* fix(clawsec-nanoclaw): normalize integrity policy and baseline paths
2026-03-09 19:30:22 +02:00
github-actions[bot] 19b53609c1 chore: CVE advisories - 46 new, 0 updated (#116)
Automated update from NVD CVE feed.
Keywords: OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys
Poll window: 2026-03-01T18:07:41Z to 2026-03-09T06:18:51.000Z

Co-authored-by: davida-ps <232346510+davida-ps@users.noreply.github.com>
2026-03-09 10:10:59 +02:00
53 changed files with 10042 additions and 158 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
ruff==0.15.2
bandit==1.9.3
bandit==1.9.4
+4 -4
View File
@@ -20,7 +20,7 @@ jobs:
- windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
cache: 'npm'
@@ -83,7 +83,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
cache: 'npm'
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
cache: 'npm'
@@ -123,7 +123,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
cache: 'npm'
+1 -1
View File
@@ -318,7 +318,7 @@ jobs:
ls -la public/checksums.json public/checksums.sig public/signing-public.pem
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
cache: 'npm'
+1 -1
View File
@@ -89,7 +89,7 @@ jobs:
signature_file: public/checksums.sig
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
cache: 'npm'
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: SARIF file
path: results.sarif
+111 -18
View File
@@ -17,6 +17,9 @@ on:
permissions: read-all
env:
CLAWHUB_CLI_VERSION: 0.7.0
concurrency:
group: skill-release-${{ github.ref }}
cancel-in-progress: false
@@ -636,7 +639,7 @@ jobs:
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
@@ -849,9 +852,8 @@ jobs:
VERSION="${{ steps.parse.outputs.version }}"
if [ ! -f "$SKILL_PATH/CHANGELOG.md" ]; then
echo "No CHANGELOG.md found"
echo "changelog=" >> $GITHUB_OUTPUT
exit 0
echo "::error::Missing required changelog file: $SKILL_PATH/CHANGELOG.md"
exit 1
fi
# Extract the changelog section for this version
@@ -865,18 +867,19 @@ jobs:
' "$SKILL_PATH/CHANGELOG.md" | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
if [ -z "$CHANGELOG_ENTRY" ]; then
echo "No changelog entry found for version $VERSION"
echo "changelog=" >> $GITHUB_OUTPUT
else
echo "Found changelog entry for version $VERSION"
# Use multiline output format for GitHub Actions
{
echo "changelog<<EOF"
echo "$CHANGELOG_ENTRY"
echo "EOF"
} >> $GITHUB_OUTPUT
echo "::error::No changelog entry found for version $VERSION in $SKILL_PATH/CHANGELOG.md"
echo "::error::Expected heading format: ## [$VERSION] - YYYY-MM-DD"
exit 1
fi
echo "Found changelog entry for version $VERSION"
# Use multiline output format for GitHub Actions
{
echo "changelog<<EOF"
echo "$CHANGELOG_ENTRY"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
@@ -895,6 +898,9 @@ jobs:
npx clawhub@latest install ${{ steps.parse.outputs.skill_name }}
```
**If you already have `clawsec-suite` installed:**
Ask your agent to pull `${{ steps.parse.outputs.skill_name }}` from the ClawSec catalog and it will handle setup and verification automatically.
**Manual download with verification:**
```bash
# 1. Download the release archive, checksums, and signing material
@@ -1000,13 +1006,57 @@ jobs:
- name: Setup Node
if: needs.release-tag.outputs.publishable == 'true'
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
- name: Install clawhub CLI
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
run: npm install -g clawhub@0.7.0
run: npm install -g clawhub@${CLAWHUB_CLI_VERSION}
- name: Patch clawhub publish payload workaround
# Temporary: clawhub@0.7.0 publish payload is missing acceptLicenseTerms.
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
run: |
node <<'NODE'
const { execSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const npmRoot = execSync("npm root -g", { encoding: "utf8" }).trim();
const publishScriptPath = path.join(
npmRoot,
"clawhub",
"dist",
"cli",
"commands",
"publish.js"
);
if (!fs.existsSync(publishScriptPath)) {
throw new Error(`clawhub publish script not found: ${publishScriptPath}`);
}
const original = fs.readFileSync(publishScriptPath, "utf8");
if (original.includes("acceptLicenseTerms: true")) {
console.log(`[patch-clawhub] Already patched: ${publishScriptPath}`);
process.exit(0);
}
const payloadPattern = /changelog,\r?\n(\s*)tags,/;
if (!payloadPattern.test(original)) {
throw new Error(
`[patch-clawhub] Could not find expected publish payload pattern in ${publishScriptPath}`
);
}
const patched = original.replace(
payloadPattern,
(_, indent) => `changelog,\n${indent}acceptLicenseTerms: true,\n${indent}tags,`
);
fs.writeFileSync(publishScriptPath, patched, "utf8");
console.log(`[patch-clawhub] Patched: ${publishScriptPath}`);
NODE
- name: Login to ClawHub
if: needs.release-tag.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
@@ -1112,12 +1162,55 @@ jobs:
echo "Skill is publishable to ClawHub"
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
- name: Install clawhub CLI
run: npm install -g clawhub@0.7.0
run: npm install -g clawhub@${CLAWHUB_CLI_VERSION}
- name: Patch clawhub publish payload workaround
# Temporary: clawhub@0.7.0 publish payload is missing acceptLicenseTerms.
run: |
node <<'NODE'
const { execSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const npmRoot = execSync("npm root -g", { encoding: "utf8" }).trim();
const publishScriptPath = path.join(
npmRoot,
"clawhub",
"dist",
"cli",
"commands",
"publish.js"
);
if (!fs.existsSync(publishScriptPath)) {
throw new Error(`clawhub publish script not found: ${publishScriptPath}`);
}
const original = fs.readFileSync(publishScriptPath, "utf8");
if (original.includes("acceptLicenseTerms: true")) {
console.log(`[patch-clawhub] Already patched: ${publishScriptPath}`);
process.exit(0);
}
const payloadPattern = /changelog,\r?\n(\s*)tags,/;
if (!payloadPattern.test(original)) {
throw new Error(
`[patch-clawhub] Could not find expected publish payload pattern in ${publishScriptPath}`
);
}
const patched = original.replace(
payloadPattern,
(_, indent) => `changelog,\n${indent}acceptLicenseTerms: true,\n${indent}tags,`
);
fs.writeFileSync(publishScriptPath, patched, "utf8");
console.log(`[patch-clawhub] Patched: ${publishScriptPath}`);
NODE
- name: Login to ClawHub
run: |
+5 -2
View File
@@ -159,7 +159,9 @@ See [`skills/clawsec-nanoclaw/INSTALL.md`](skills/clawsec-nanoclaw/INSTALL.md) f
The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and maintains security skills from the ClawSec catalog.
### Skills in the Suite
`clawsec-suite` is optional orchestration; skills can still be installed directly as standalone packages.
### ClawSec Skills
| Skill | Description | Installation | Compatibility |
|-------|-------------|--------------|---------------|
@@ -433,8 +435,9 @@ npm run build
│ ├── populate-local-wiki.sh # Local wiki llms export populator
│ └── release-skill.sh # Manual skill release helper
├── skills/
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills)
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills - start here and have your agent do the rest)
│ ├── clawsec-feed/ # 📡 Advisory feed skill
│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST)
│ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite
│ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks
│ ├── clawtributor/ # 🤝 Community reporting skill
+1629 -1
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
SJ1weYVVi723M8f6s8es6rg34CSPKxbvlBy1QIXdS0giskd5KTADTDLr2STqUCuWpaV7U+JQa/1eWqNX2oJ+Aw==
t39IWpreVBdG2SDMBYrKw3On1UlrimlglhnIiBzvfXTV2gBvxOI815tHsGqfMWsRTvZ6gqbTO1njQy44392pBQ==
+2 -1
View File
@@ -85,7 +85,8 @@ export default [
}
},
rules: {
'no-empty': ['error', { allowEmptyCatch: true }]
'no-empty': ['error', { allowEmptyCatch: true }],
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]
}
},
// Node.js scripts (.js files in scripts directory)
+13 -11
View File
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@eslint/js": "~9.28.0",
"@types/node": "^25.2.3",
"@types/node": "^25.4.0",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.56.0",
"@vitejs/plugin-react": "^5.1.4",
@@ -26,7 +26,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"fast-check": "^4.5.3",
"typescript": "~5.8.2",
"typescript": "~5.9.3",
"vite": "^7.3.1"
}
},
@@ -1357,13 +1357,13 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"version": "25.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@types/react": {
@@ -5629,9 +5629,11 @@
}
},
"node_modules/typescript": {
"version": "5.8.3",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5658,9 +5660,9 @@
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
+2 -2
View File
@@ -23,7 +23,7 @@
},
"devDependencies": {
"@eslint/js": "~9.28.0",
"@types/node": "^25.2.3",
"@types/node": "^25.4.0",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.56.0",
"@vitejs/plugin-react": "^5.1.4",
@@ -31,7 +31,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"fast-check": "^4.5.3",
"typescript": "~5.8.2",
"typescript": "~5.9.3",
"vite": "^7.3.1"
},
"overrides": {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
SJ1weYVVi723M8f6s8es6rg34CSPKxbvlBy1QIXdS0giskd5KTADTDLr2STqUCuWpaV7U+JQa/1eWqNX2oJ+Aw==
t39IWpreVBdG2SDMBYrKw3On1UlrimlglhnIiBzvfXTV2gBvxOI815tHsGqfMWsRTvZ6gqbTO1njQy44392pBQ==
+14
View File
@@ -5,6 +5,20 @@ All notable changes to the ClawSec NanoClaw compatibility skill will be document
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.3] - 2026-03-09
### Security
- Removed runtime public-key override from host-side package signature verification; verification now always uses the pinned ClawSec key.
- Removed unsigned-package override path in host-side verification flow.
- Added strict package/signature path policy for signature verification (`/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads`) with absolute-path, extension, symlink, and realpath boundary checks.
- Added policy-bound path enforcement for integrity approvals: approvals now require normalized paths that are explicitly present in non-ignored integrity policy targets.
### Changed
- Updated MCP signature verification tool docs and behavior to align with bounded path policy and pinned-key-only verification.
- Added regression tests for signature-verification and integrity-approval hardening invariants.
## [0.0.2] - 2026-02-28
### Added
+2
View File
@@ -140,6 +140,8 @@ From within a NanoClaw agent session, the following tools should be available:
**Signature Verification** (mcp-tools/signature-verification.ts):
- `clawsec_verify_skill_package` - Verify Ed25519 signature on skill packages
- Uses pinned ClawSec public key (no runtime key override)
- Accepts staged package/signature paths only under `/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads`
**Integrity Monitoring** (mcp-tools/integrity-tools.ts):
- `clawsec_check_integrity` - Check protected files for unauthorized changes
+2 -1
View File
@@ -1,6 +1,6 @@
---
name: clawsec-nanoclaw
version: 0.0.2
version: 0.0.3
description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot
---
@@ -186,6 +186,7 @@ if (advisory.exploitability_score === 'high' || advisory.severity === 'critical'
**Update Frequency**: Every 6 hours (automatic)
**Signature Verification**: Ed25519 signed feeds
**Package Verification Policy**: pinned key only, bounded package/signature paths
**Cache Location**: `/workspace/project/data/clawsec-advisory-cache.json`
+10 -17
View File
@@ -130,16 +130,21 @@ console.log('Safe to proceed with installation.');
### MCP Tool: `clawsec_verify_skill_package`
**Parameters:**
- `packagePath` (required): Absolute path to skill package (`.tar.gz` or `.zip`)
- `packagePath` (required): Absolute path to skill package (`.tar.gz`, `.tar`, `.tgz`, or `.zip`)
- `signaturePath` (optional): Path to signature file (auto-detects `.sig` if omitted)
Path policy:
- Files must be under one of: `/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads`
- Symlinks are rejected
- Signatures must use `.sig`
**Returns:**
```typescript
{
success: boolean, // Operation completed without errors
valid: boolean, // Signature is cryptographically valid
recommendation: string, // "install" | "block" | "review"
signer: string, // "clawsec" or custom signer
signer: string, // "clawsec"
algorithm: "Ed25519", // Signature algorithm
verifiedAt: string, // ISO timestamp
packageInfo: {
@@ -335,22 +340,10 @@ openssl pkey -pubin -in feed-signing-public.pem -outform DER | \
# Expected: <will be filled in after key generation>
```
### Using Custom Public Keys
### Public Key Policy
For organizational deployments with custom skill publishers:
```typescript
// Load custom public key
const customPublicKey = fs.readFileSync('/path/to/org-public.pem', 'utf8');
// Verify with custom key (not pinned ClawSec key)
const verification = await tools.clawsec_verify_skill_package({
packagePath: '/tmp/org-skill.tar.gz',
publicKeyPath: '/path/to/org-public.pem' // Custom key
});
```
**Note**: The MCP tool currently uses the pinned key. Custom key support via `publicKeyPem` parameter requires host-side implementation.
The verifier always uses the pinned ClawSec public key from this skill package.
Runtime public-key overrides are intentionally not supported.
### Key Rotation
@@ -312,7 +312,7 @@ export class IntegrityMonitor {
if (target.path) {
// Direct path
targets.push({
path: target.path,
path: path.resolve(target.path),
mode: target.mode,
priority: target.priority
});
@@ -336,6 +336,18 @@ export class IntegrityMonitor {
return targets;
}
private normalizeBaselines(manifest: BaselinesManifest): BaselinesManifest {
const normalizedFiles: Record<string, FileBaseline> = {};
for (const [filePath, baseline] of Object.entries(manifest.files || {})) {
normalizedFiles[path.resolve(filePath)] = baseline;
}
return {
...manifest,
files: normalizedFiles,
};
}
// --------------------------------------------------------------------------
// Baseline Management
// --------------------------------------------------------------------------
@@ -343,7 +355,7 @@ export class IntegrityMonitor {
private loadBaselines(): BaselinesManifest {
if (fs.existsSync(this.baselinesPath)) {
const raw = fs.readFileSync(this.baselinesPath, 'utf-8');
return JSON.parse(raw);
return this.normalizeBaselines(JSON.parse(raw));
}
return {
@@ -585,37 +597,43 @@ export class IntegrityMonitor {
throw new Error('Baselines not loaded');
}
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
const normalizedFilePath = path.resolve(filePath);
if (!fs.existsSync(normalizedFilePath)) {
throw new Error(`File not found: ${normalizedFilePath}`);
}
refuseSymlink(filePath);
refuseSymlink(normalizedFilePath);
const previousSha = this.baselines.files[filePath]?.sha256;
const currentSha = sha256File(filePath);
const targets = this.resolveTargets();
const target = targets.find(t => t.path === normalizedFilePath);
if (!target || target.mode === 'ignore') {
throw new Error(`File ${normalizedFilePath} not in policy`);
}
const previousSha = this.baselines.files[normalizedFilePath]?.sha256;
const currentSha = sha256File(normalizedFilePath);
// Generate diff
const snapshot = path.join(this.approvedDir, path.basename(filePath));
const snapshot = path.join(this.approvedDir, path.basename(normalizedFilePath));
const oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : '';
const newText = fs.readFileSync(filePath, 'utf-8');
const diff = unifiedDiff(oldText, newText, `approved/${path.basename(filePath)}`, path.basename(filePath));
const newText = fs.readFileSync(normalizedFilePath, 'utf-8');
const diff = unifiedDiff(
oldText,
newText,
`approved/${path.basename(normalizedFilePath)}`,
path.basename(normalizedFilePath)
);
const patchPath = path.join(
this.patchesDir,
`${new Date().toISOString().replace(/[:.]/g, '-')}-approve-${safePatchTag(path.basename(filePath))}.patch`
`${new Date().toISOString().replace(/[:.]/g, '-')}-approve-${safePatchTag(path.basename(normalizedFilePath))}.patch`
);
fs.writeFileSync(patchPath, diff);
// Update baseline
if (!this.baselines.files[filePath]) {
// Find mode from policy
const targets = this.resolveTargets();
const target = targets.find(t => t.path === filePath);
if (!target) {
throw new Error(`File ${filePath} not in policy`);
}
this.baselines.files[filePath] = {
if (!this.baselines.files[normalizedFilePath]) {
this.baselines.files[normalizedFilePath] = {
sha256: currentSha,
approved_at: utcNowIso(),
approved_by: actor,
@@ -623,13 +641,13 @@ export class IntegrityMonitor {
priority: target.priority
};
} else {
this.baselines.files[filePath].sha256 = currentSha;
this.baselines.files[filePath].approved_at = utcNowIso();
this.baselines.files[filePath].approved_by = actor;
this.baselines.files[normalizedFilePath].sha256 = currentSha;
this.baselines.files[normalizedFilePath].approved_at = utcNowIso();
this.baselines.files[normalizedFilePath].approved_by = actor;
}
// Update snapshot
fs.copyFileSync(filePath, snapshot);
fs.copyFileSync(normalizedFilePath, snapshot);
// Save and audit
this.saveBaselines();
@@ -639,7 +657,7 @@ export class IntegrityMonitor {
event: 'approve',
actor,
note,
path: filePath,
path: normalizedFilePath,
expected_sha: previousSha,
found_sha: currentSha,
patch_path: patchPath
@@ -656,8 +674,9 @@ export class IntegrityMonitor {
throw new Error('Baselines not loaded');
}
const files = filePath
? { [filePath]: this.baselines.files[filePath] }
const normalizedFilePath = filePath ? path.resolve(filePath) : null;
const files = normalizedFilePath
? { [normalizedFilePath]: this.baselines.files[normalizedFilePath] }
: this.baselines.files;
return {
@@ -61,7 +61,7 @@ export async function handleAdvisoryIpc(
case 'verify_skill_signature': {
// Skill signature verification (Phase 1)
const { requestId, packagePath, signaturePath, publicKeyPem, allowUnsigned } = task;
const { requestId, packagePath, signaturePath } = task;
logger.info({ sourceGroup, requestId, packagePath }, 'Verifying skill signature');
@@ -73,8 +73,6 @@ export async function handleAdvisoryIpc(
const result = await deps.signatureVerifier.verify({
packagePath,
signaturePath,
publicKeyPem,
allowUnsigned: allowUnsigned || false,
});
await writeResponse(requestId, {
@@ -40,8 +40,81 @@ export interface VerificationResult {
export interface VerifyParams {
packagePath: string;
signaturePath: string;
publicKeyPem?: string; // Optional override of pinned key
allowUnsigned?: boolean; // Allow missing signature (default: false)
}
const ALLOWED_PACKAGE_ROOTS = [
'/tmp',
'/var/tmp',
'/workspace/ipc',
'/workspace/project/data',
'/workspace/project/tmp',
'/workspace/project/downloads',
] as const;
const ALLOWED_PACKAGE_EXTENSIONS = ['.zip', '.tar', '.tgz', '.tar.gz'] as const;
function isWithinAllowedRoots(filePath: string): boolean {
return ALLOWED_PACKAGE_ROOTS.some((root) => filePath === root || filePath.startsWith(`${root}/`));
}
function hasAllowedPackageExtension(filePath: string): boolean {
return ALLOWED_PACKAGE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
}
function normalizeAndValidatePath(rawPath: string, kind: 'package' | 'signature'): string {
if (!path.isAbsolute(rawPath)) {
throw new SecurityPolicyError(`${kind} path must be absolute`);
}
const resolved = path.resolve(rawPath);
if (!isWithinAllowedRoots(resolved)) {
throw new SecurityPolicyError(
`${kind} path must be under allowed roots: ${ALLOWED_PACKAGE_ROOTS.join(', ')}`
);
}
if (kind === 'package' && !hasAllowedPackageExtension(resolved)) {
throw new SecurityPolicyError(
`package path must use one of: ${ALLOWED_PACKAGE_EXTENSIONS.join(', ')}`
);
}
if (kind === 'signature' && !resolved.endsWith('.sig')) {
throw new SecurityPolicyError('signature path must end with .sig');
}
return resolved;
}
function ensureExistingRegularFile(filePath: string, kind: 'package' | 'signature'): string {
if (!fs.existsSync(filePath)) {
throw new SecurityPolicyError(`${kind} file not found: ${filePath}`);
}
const stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) {
throw new SecurityPolicyError(`${kind} path cannot be a symlink`);
}
if (!stat.isFile()) {
throw new SecurityPolicyError(`${kind} path must be a regular file`);
}
const realPath = fs.realpathSync(filePath);
if (!isWithinAllowedRoots(realPath)) {
throw new SecurityPolicyError(`${kind} real path escapes allowed roots`);
}
return realPath;
}
function validatePackagePath(rawPackagePath: string): string {
const resolved = normalizeAndValidatePath(rawPackagePath, 'package');
return ensureExistingRegularFile(resolved, 'package');
}
function validateSignaturePath(rawSignaturePath: string): string {
const resolved = normalizeAndValidatePath(rawSignaturePath, 'signature');
return ensureExistingRegularFile(resolved, 'signature');
}
/**
@@ -68,70 +141,40 @@ export class SkillSignatureVerifier {
const {
packagePath,
signaturePath,
publicKeyPem,
allowUnsigned = false
} = params;
// Validate package file exists
if (!fs.existsSync(packagePath)) {
let validatedPackagePath: string;
let validatedSignaturePath: string;
try {
validatedPackagePath = validatePackagePath(packagePath);
validatedSignaturePath = validateSignaturePath(signaturePath);
} catch (error) {
return {
valid: false,
signer: null,
packageHash: '',
verifiedAt: new Date().toISOString(),
algorithm: 'Ed25519',
error: `Package file not found: ${packagePath}`
error: error instanceof Error ? error.message : String(error),
};
}
// Check signature file exists
if (!fs.existsSync(signaturePath)) {
if (allowUnsigned) {
// Unsigned allowed - compute hash but mark invalid
const packageHash = sha256File(packagePath);
return {
valid: false,
signer: null,
packageHash,
verifiedAt: new Date().toISOString(),
algorithm: 'Ed25519',
error: 'No signature file found (unsigned package)'
};
} else {
// Unsigned not allowed - fail
// Load pinned ClawSec key only
let keyPem: string;
try {
if (!fs.existsSync(this.publicKeyPath)) {
return {
valid: false,
signer: null,
packageHash: '',
verifiedAt: new Date().toISOString(),
algorithm: 'Ed25519',
error: `Signature file not found: ${signaturePath}`
error: `Public key file not found: ${this.publicKeyPath}`
};
}
}
// Load public key (either custom or pinned)
let keyPem: string;
try {
if (publicKeyPem) {
// Custom key provided - validate format
loadPublicKey(publicKeyPem); // Throws if invalid
keyPem = publicKeyPem;
} else {
// Load pinned ClawSec key
if (!fs.existsSync(this.publicKeyPath)) {
return {
valid: false,
signer: null,
packageHash: '',
verifiedAt: new Date().toISOString(),
algorithm: 'Ed25519',
error: `Public key file not found: ${this.publicKeyPath}`
};
}
keyPem = fs.readFileSync(this.publicKeyPath, 'utf8');
loadPublicKey(keyPem); // Validate pinned key
}
keyPem = fs.readFileSync(this.publicKeyPath, 'utf8');
loadPublicKey(keyPem); // Validate pinned key
} catch (error) {
if (error instanceof SecurityPolicyError) {
return {
@@ -156,7 +199,7 @@ export class SkillSignatureVerifier {
// Compute package hash (always, for integrity tracking)
let packageHash: string;
try {
packageHash = sha256File(packagePath);
packageHash = sha256File(validatedPackagePath);
} catch (error) {
return {
valid: false,
@@ -170,8 +213,8 @@ export class SkillSignatureVerifier {
// Verify signature
const verificationResult = verifyDetachedSignatureWithDetails(
packagePath,
signaturePath,
validatedPackagePath,
validatedSignaturePath,
keyPem
);
-2
View File
@@ -224,8 +224,6 @@ export interface VerifySkillSignatureRequest {
timestamp: string;
packagePath: string;
signaturePath: string;
publicKeyPem?: string; // Optional: override default public key
allowUnsigned?: boolean; // Optional: allow missing signature (default: false)
}
/**
@@ -18,6 +18,55 @@ declare function writeIpcFile(dir: string, data: any): void;
declare const TASKS_DIR: string;
declare const groupFolder: string;
const ALLOWED_VERIFICATION_ROOTS = [
'/tmp',
'/var/tmp',
'/workspace/ipc',
'/workspace/project/data',
'/workspace/project/tmp',
'/workspace/project/downloads',
] as const;
const ALLOWED_PACKAGE_EXTENSIONS = ['.zip', '.tar', '.tgz', '.tar.gz'] as const;
function isWithinAllowedRoots(filePath: string): boolean {
return ALLOWED_VERIFICATION_ROOTS.some((root) => filePath === root || filePath.startsWith(`${root}/`));
}
function validatePackagePath(rawPath: string): string {
if (!path.isAbsolute(rawPath)) {
throw new Error('packagePath must be absolute');
}
const resolved = path.resolve(rawPath);
if (!isWithinAllowedRoots(resolved)) {
throw new Error(`packagePath must be under: ${ALLOWED_VERIFICATION_ROOTS.join(', ')}`);
}
if (!ALLOWED_PACKAGE_EXTENSIONS.some((ext) => resolved.endsWith(ext))) {
throw new Error(`packagePath must end with one of: ${ALLOWED_PACKAGE_EXTENSIONS.join(', ')}`);
}
return resolved;
}
function validateSignaturePath(rawPath: string): string {
if (!path.isAbsolute(rawPath)) {
throw new Error('signaturePath must be absolute');
}
const resolved = path.resolve(rawPath);
if (!isWithinAllowedRoots(resolved)) {
throw new Error(`signaturePath must be under: ${ALLOWED_VERIFICATION_ROOTS.join(', ')}`);
}
if (!resolved.endsWith('.sig')) {
throw new Error('signaturePath must end with .sig');
}
return resolved;
}
// Result waiting helper
async function waitForResult(requestId: string, timeoutMs: number = 5000): Promise<any> {
const resultDir = '/workspace/ipc/clawsec_results';
@@ -49,10 +98,13 @@ server.tool(
},
async (args: { packagePath: string; signaturePath?: string }) => {
const requestId = `verify-signature-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const sigPath = args.signaturePath || `${args.packagePath}.sig`;
let packagePath: string;
let sigPath: string;
// Validate package file exists
if (!fs.existsSync(args.packagePath)) {
try {
packagePath = validatePackagePath(args.packagePath);
sigPath = validateSignaturePath(args.signaturePath || `${packagePath}.sig`);
} catch (error) {
return {
content: [{
type: 'text' as const,
@@ -60,7 +112,23 @@ server.tool(
success: false,
valid: false,
recommendation: 'block',
error: `Package file not found: ${args.packagePath}`
error: error instanceof Error ? error.message : String(error),
}, null, 2)
}],
isError: true
};
}
// Validate package file exists
if (!fs.existsSync(packagePath)) {
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
success: false,
valid: false,
recommendation: 'block',
error: `Package file not found: ${packagePath}`
}, null, 2)
}],
isError: true
@@ -73,7 +141,7 @@ server.tool(
requestId,
groupFolder,
timestamp: new Date().toISOString(),
packagePath: args.packagePath,
packagePath,
signaturePath: sigPath,
});
@@ -90,7 +158,7 @@ server.tool(
success: false,
valid: false,
recommendation: 'block',
packagePath: args.packagePath,
packagePath,
signaturePath: sigPath,
error: result.message || 'Verification failed',
reason: result.error?.code || 'UNKNOWN_ERROR'
@@ -109,7 +177,7 @@ server.tool(
success: true,
valid: false,
recommendation: 'block',
packagePath: args.packagePath,
packagePath,
signaturePath: sigPath,
reason: result.data?.error || 'Signature verification failed',
packageInfo: {
@@ -128,13 +196,13 @@ server.tool(
success: true,
valid: true,
recommendation: 'install',
packagePath: args.packagePath,
packagePath,
signaturePath: sigPath,
signer: result.data.signer,
algorithm: result.data.algorithm,
verifiedAt: result.data.verifiedAt,
packageInfo: {
size: fs.statSync(args.packagePath).size,
size: fs.statSync(packagePath).size,
sha256: result.data.packageHash
}
}, null, 2)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawsec-nanoclaw",
"version": "0.0.2",
"version": "0.0.3",
"description": "ClawSec security suite for NanoClaw - Advisory feed monitoring, MCP tools for vulnerability checking, and Ed25519 signature verification for containerized WhatsApp bot agents",
"author": "prompt-security",
"license": "AGPL-3.0-or-later",
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SKILL_ROOT = path.resolve(__dirname, '..');
function readSkillFile(relativePath) {
return fs.readFileSync(path.join(SKILL_ROOT, relativePath), 'utf8');
}
test('signature verifier enforces pinned key and path policy', () => {
const source = readSkillFile('host-services/skill-signature-handler.ts');
assert.ok(!source.includes('publicKeyPem?: string'), 'publicKeyPem override must be removed');
assert.ok(!source.includes('allowUnsigned?: boolean'), 'allowUnsigned override must be removed');
assert.ok(source.includes('const ALLOWED_PACKAGE_ROOTS'), 'must define allowed package roots');
assert.ok(source.includes('validatePackagePath('), 'must validate package path before hashing');
assert.ok(source.includes('validateSignaturePath('), 'must validate signature path before verification');
});
test('IPC advisory handler does not forward key or unsigned overrides', () => {
const source = readSkillFile('host-services/ipc-handlers.ts');
assert.ok(!source.includes('publicKeyPem'), 'IPC handler must not accept publicKeyPem override');
assert.ok(!source.includes('allowUnsigned'), 'IPC handler must not accept allowUnsigned override');
});
test('MCP signature tool validates filesystem boundaries', () => {
const source = readSkillFile('mcp-tools/signature-verification.ts');
assert.ok(source.includes('const ALLOWED_VERIFICATION_ROOTS'), 'must define allowed verification roots');
assert.ok(source.includes('validatePackagePath('), 'must validate package path in MCP layer');
assert.ok(source.includes('validateSignaturePath('), 'must validate signature path in MCP layer');
});
test('integrity approvals are restricted to policy targets', () => {
const source = readSkillFile('guardian/integrity-monitor.ts');
assert.ok(source.includes('const normalizedFilePath = path.resolve(filePath);'), 'must normalize approved path');
assert.ok(
source.includes("if (!target || target.mode === 'ignore')"),
'must require approved file to exist in non-ignored policy target list'
);
});
test('integrity targets and baselines use normalized absolute paths', () => {
const source = readSkillFile('guardian/integrity-monitor.ts');
assert.ok(source.includes('path: path.resolve(target.path)'), 'resolveTargets must normalize direct target paths');
assert.ok(source.includes('const normalizedFilePath = path.resolve(filePath);'), 'status/approval lookups must normalize file paths');
assert.ok(source.includes('normalizedFiles[path.resolve(filePath)] = baseline;'), 'loaded baselines must be normalized to absolute keys');
});
+31
View File
@@ -0,0 +1,31 @@
# Changelog
All notable changes to the ClawSec Scanner will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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 `.ts` hooks) to `info` coverage 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
+497
View File
@@ -0,0 +1,497 @@
---
name: clawsec-scanner
version: 0.0.2
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 DAST hook execution testing for OpenClaw hooks.
homepage: https://clawsec.prompt.security
clawdis:
emoji: "🔍"
requires:
bins: [node, npm, python3, pip-audit, semgrep, bandit, jq, curl]
---
# ClawSec Scanner
Comprehensive security scanner for agent platforms that automates vulnerability detection across multiple dimensions:
- **Dependency Scanning**: Analyzes npm and Python dependencies using `npm audit` and `pip-audit` with 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 dynamic analysis with real OpenClaw hook execution harness (malicious input, timeout, output bounds, event mutation safety)
- **Unified Reporting**: Consolidated vulnerability reports with severity classification and remediation guidance
- **Continuous Monitoring**: OpenClaw hook integration for automated periodic scanning
## Features
### Multi-Engine Scanning
The scanner orchestrates four complementary scan types to provide comprehensive vulnerability coverage:
1. **Dependency Scanning**
- Executes `npm audit --json` and `pip-audit -f json` as 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 `Vulnerability` schema
3. **Static Analysis (SAST)**
- **Semgrep** for JavaScript/TypeScript: Detects security issues using `--config auto` or `--config p/security-audit`
- **Bandit** for Python: Leverages existing `pyproject.toml` configuration
- Identifies: hardcoded secrets (API keys, tokens), command injection (`eval`, `exec`), path traversal, unsafe deserialization
4. **Dynamic Analysis (DAST)**
- Real hook execution harness for OpenClaw hook handlers discovered from `HOOK.md` metadata
- Verifies: malicious input resilience, timeout behavior, output amplification bounds, and core event mutation safety
- 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:
```typescript
{
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-zzzz
- `source`: npm-audit | pip-audit | osv | nvd | github | sast | dast
- `severity`: critical | high | medium | low | info
- `package`: Package name (or 'N/A' for SAST/DAST)
- `version`: Affected version
- `fixed_version`: First version with fix (if available)
- `title`: Short description
- `description`: Full advisory text
- `references`: URLs for more info
- `discovered_at`: ISO 8601 timestamp
### OpenClaw Integration
Automated continuous monitoring via hook:
- Runs scanner on configurable interval (default: 86400s / 24 hours)
- Triggers on `agent:bootstrap` and `command:new` events
- Posts findings to `event.messages` array with severity summary
- Rate-limited by `CLAWSEC_SCANNER_INTERVAL` environment variable
## Installation
### Prerequisites
Verify required binaries are available:
```bash
# 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 --version
```
### Option A: Via clawhub (recommended)
```bash
npx clawhub@latest install clawsec-scanner
```
### Option B: Manual installation with verification
```bash
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
```bash
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" --help
```
**CLI 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:
```bash
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:bootstrap` and `command:new` events
- Respects `CLAWSEC_SCANNER_INTERVAL` rate 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
```bash
# 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 # Dynamic security testing orchestration
└── dast_hook_executor.mjs # Isolated real hook execution harness
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 trigger
```
### Fail-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:
```javascript
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 audit` requires lockfile to run
- Run `npm install` in target directory to generate
- Scanner continues with other scan types if npm audit fails
**"NVD API rate limit exceeded"**
- Set `CLAWSEC_NVD_API_KEY` environment 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-audit` or `pip install pip-audit`
- Verify: `which pip-audit`
- Add to PATH if installed in non-standard location
**"Semgrep binary missing"**
- Install: `pip install semgrep` OR `brew install semgrep`
- Requires Python 3.8+ runtime
- Alternative: use Docker image `returntocorp/semgrep`
**"TypeScript hook not executable in DAST harness"**
- The DAST harness executes real hook handlers and transpiles `handler.ts` files when a TypeScript compiler is available
- Install TypeScript in the scanner environment: `npm install -D typescript` (or provide `handler.js`/`handler.mjs`)
- Without a compiler, scanner reports an `info`-level coverage finding instead of a high-severity vulnerability
**"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:
```bash
# 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 text
```
## Development
### Running Tests
```bash
# 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 harness execution
```
### Linting
```bash
# 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/*.sh
```
### Adding Custom Semgrep Rules
Create custom rules in `.semgrep/rules/`:
```yaml
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:
```javascript
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:
```bash
npx clawhub@latest install clawsec-suite
# Then use clawsec-suite to discover and install clawsec-scanner
```
## Security 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.2 (Current)
- [x] Dependency scanning (npm audit, pip-audit)
- [x] CVE database integration (OSV, NVD, GitHub Advisory)
- [x] SAST analysis (Semgrep, Bandit)
- [x] Real OpenClaw hook execution harness for DAST
- [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/
@@ -0,0 +1,74 @@
---
name: clawsec-scanner-hook
description: Periodic vulnerability scanning for installed skills and dependencies with configurable scan intervals.
metadata: { "openclaw": { "events": ["agent:bootstrap", "command:new"] } }
---
# ClawSec Scanner Hook
This hook performs comprehensive vulnerability scanning on installed skills and their dependencies on:
- `agent:bootstrap`
- `command: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 (default `86400` / 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 to `1` to disable dependency scanning (npm audit, pip-audit).
- `CLAWSEC_SKIP_SAST`: Set to `1` to disable static analysis (Semgrep, Bandit).
- `CLAWSEC_SKIP_DAST`: Set to `1` to disable dynamic analysis (hook security tests).
- `CLAWSEC_SKIP_CVE_LOOKUP`: Set to `1` to disable CVE database enrichment.
### Advanced Options
- `CLAWSEC_SCANNER_TIMEOUT`: Maximum scan duration in seconds before timeout (default `300` / 5 minutes).
- `CLAWSEC_SCANNER_FORMAT`: Output format for findings (`json` or `text`, default `text`).
- `CLAWSEC_SCANNER_MIN_SEVERITY`: Minimum severity to report (`critical`, `high`, `medium`, `low`, `info`, default `medium`).
- `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 runtime
- `npm` - For npm audit execution
- `python3` (3.10+) - Python runtime
- `pip-audit` - Python dependency scanner
- `semgrep` - JavaScript/TypeScript static analysis
- `bandit` - Python static analysis
- `jq` - JSON parsing and merging
- `curl` - API requests (fallback)
Missing binaries will be logged as warnings; available tools will still run.
@@ -0,0 +1,313 @@
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> => {
// DAST harness mode executes hook handlers directly; skip recursive scanner runs.
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;
View File
+251
View File
@@ -0,0 +1,251 @@
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 : [""];
}
+45
View File
@@ -0,0 +1,45 @@
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;
};
+139
View File
@@ -0,0 +1,139 @@
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;
}
}
@@ -0,0 +1,273 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
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");
}
if (!parsed.eventB64) {
throw new Error("Missing required --event");
}
if (!parsed.contextB64) {
throw new Error("Missing required --context");
}
return parsed;
}
function decodeBase64Json(value, label) {
try {
const decoded = Buffer.from(value, "base64").toString("utf8");
return JSON.parse(decoded);
} catch (error) {
throw new Error(`Failed to decode ${label}: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function loadTypeScriptCompiler() {
if (process.env.CLAWSEC_DAST_DISABLE_TYPESCRIPT === "1") {
return null;
}
try {
const imported = await import("typescript");
return imported.default || imported;
} catch {
// Ignore and try require path next.
}
try {
const req = createRequire(import.meta.url);
return req("typescript");
} catch {
return null;
}
}
async function importTypeScriptModule(tsPath) {
const tsCompiler = await loadTypeScriptCompiler();
if (!tsCompiler || typeof tsCompiler.transpileModule !== "function") {
throw new Error(
`Cannot execute TypeScript hook (${tsPath}): typescript compiler not available. ` +
"Install 'typescript' or provide a JavaScript handler file.",
);
}
const source = await fs.readFile(tsPath, "utf8");
const transpiled = tsCompiler.transpileModule(source, {
compilerOptions: {
module: tsCompiler.ModuleKind.ESNext,
target: tsCompiler.ScriptTarget.ES2022,
moduleResolution: tsCompiler.ModuleResolutionKind.NodeNext,
esModuleInterop: true,
sourceMap: false,
inlineSourceMap: false,
declaration: false,
},
fileName: tsPath,
reportDiagnostics: false,
});
const tempFile = path.join(
path.dirname(tsPath),
`.clawsec-dast-${path.basename(tsPath, ".ts")}-${process.pid}-${Date.now()}.mjs`,
);
await fs.writeFile(tempFile, transpiled.outputText, "utf8");
try {
return await import(`${pathToFileURL(tempFile).href}?ts=${Date.now()}`);
} finally {
try {
await fs.unlink(tempFile);
} catch {
// best-effort cleanup
}
}
}
async function loadHookModule(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();
if (ext === ".ts") {
return importTypeScriptModule(fullPath);
}
return import(`${pathToFileURL(fullPath).href}?v=${Date.now()}`);
}
function resolveHandlerExport(mod, exportName) {
if (exportName && exportName !== "default") {
if (typeof mod?.[exportName] === "function") {
return mod[exportName];
}
throw new Error(`Hook export '${exportName}' is not a function`);
}
if (typeof mod?.default === "function") {
return mod.default;
}
if (typeof mod?.handler === "function") {
return mod.handler;
}
throw new Error("Hook module does not export a handler function");
}
function normalizeTimestamp(event) {
const timestamp = event?.timestamp;
if (typeof timestamp === "string" || typeof timestamp === "number") {
const parsed = new Date(timestamp);
if (!Number.isNaN(parsed.getTime())) {
event.timestamp = parsed;
}
}
}
function summarizeMessages(messages) {
if (!Array.isArray(messages)) {
return {
count: 0,
charCount: 0,
};
}
let charCount = 0;
for (const message of messages) {
if (typeof message === "string") {
charCount += message.length;
continue;
}
try {
charCount += JSON.stringify(message).length;
} catch {
charCount += 0;
}
}
return {
count: messages.length,
charCount,
};
}
function coreEventShape(event) {
return {
type: event?.type ?? null,
action: event?.action ?? null,
sessionKey: event?.sessionKey ?? null,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const event = decodeBase64Json(args.eventB64, "event payload");
const context = decodeBase64Json(args.contextB64, "context payload");
normalizeTimestamp(event);
const startedAt = Date.now();
const before = coreEventShape(event);
try {
const mod = await loadHookModule(args.handler);
const handler = resolveHandlerExport(mod, args.exportName);
await handler(event, context);
const after = coreEventShape(event);
const messageSummary = summarizeMessages(event?.messages);
const payload = {
ok: true,
duration_ms: Date.now() - startedAt,
core_before: before,
core_after: after,
messages_count: messageSummary.count,
messages_char_count: messageSummary.charCount,
};
process.stdout.write(JSON.stringify(payload));
} catch (error) {
const after = coreEventShape(event);
const messageSummary = summarizeMessages(event?.messages);
const payload = {
ok: false,
duration_ms: Date.now() - startedAt,
core_before: before,
core_after: after,
messages_count: messageSummary.count,
messages_char_count: messageSummary.charCount,
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);
});
+785
View File
@@ -0,0 +1,785 @@
#!/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 MAX_OUTPUT_MESSAGES = 25;
const MAX_OUTPUT_CHARS = 20000;
const SKIP_DIR_NAMES = new Set([
".git",
".github",
".idea",
".vscode",
"node_modules",
"dist",
"build",
"coverage",
".openclaw",
]);
const MALICIOUS_PAYLOADS = [
"<script>alert('XSS')</script>",
"'; DROP TABLE users; --",
"; rm -rf /",
"$(whoami)",
"..\\..\\..\\windows\\system32\\config\\sam",
"../../../etc/passwd",
"test\0malicious",
"A".repeat(200000),
];
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;
}
/**
* @param {string} eventKey
* @returns {{type: string, action: string}}
*/
function splitEventKey(eventKey) {
const parts = String(eventKey ?? "").split(":");
const type = parts.shift() || "command";
const action = parts.join(":") || "new";
return { type, action };
}
/**
* @param {string} eventKey
* @param {string} payload
* @param {string} targetPath
* @returns {Record<string, unknown>}
*/
export function buildEvent(eventKey, payload, targetPath) {
const { type, action } = splitEventKey(eventKey);
return {
type,
action,
sessionKey: "clawsec-dast-session",
timestamp: new Date().toISOString(),
messages: [],
context: {
content: payload,
transcript: payload,
workspaceDir: path.resolve(targetPath),
channelId: "dast-harness",
commandSource: "dast",
bootstrapFiles: [],
},
};
}
/**
* @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 {Record<string, unknown>} event
* @param {Record<string, unknown>} context
* @param {number} timeoutMs
* @returns {Promise<HarnessInvocationResult>}
*/
async function invokeHookHarness(hook, event, context, timeoutMs) {
const encodedEvent = Buffer.from(JSON.stringify(event), "utf8").toString("base64");
const encodedContext = Buffer.from(JSON.stringify(context), "utf8").toString("base64");
const args = [
HOOK_EXECUTOR_PATH,
"--handler",
hook.handlerPath,
"--export",
hook.exportName || "default",
"--event",
encodedEvent,
"--context",
encodedContext,
];
return new Promise((resolve) => {
const proc = spawn("node", args, {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
CLAWSEC_DAST_HARNESS: "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, messagesCount: number, messagesCharCount: number, coreAfter: Record<string, unknown>}}
*/
function normalizeHarnessPayload(parsed) {
if (!isObject(parsed)) {
return {
ok: false,
error: "Harness output is not an object",
messagesCount: 0,
messagesCharCount: 0,
coreAfter: {},
};
}
const ok = parsed.ok === true;
const error = typeof parsed.error === "string" ? parsed.error : "";
const messagesCount = Number(parsed.messages_count ?? 0) || 0;
const messagesCharCount = Number(parsed.messages_char_count ?? 0) || 0;
const coreAfter = isObject(parsed.core_after) ? parsed.core_after : {};
return {
ok,
error,
messagesCount,
messagesCharCount,
coreAfter,
};
}
/**
* @param {string} input
* @returns {string}
*/
function slug(input) {
return String(input)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
/**
* @param {string} reason
* @returns {boolean}
*/
function isHarnessCapabilityError(reason) {
const normalized = String(reason ?? "").toLowerCase();
return (
normalized.includes("typescript compiler not available")
|| normalized.includes("does not export a handler function")
|| normalized.includes("is not a function")
);
}
/**
* @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);
for (const eventKey of hook.events) {
const safeEvent = buildEvent(eventKey, "safe baseline input", targetPath);
const safeContext = {
skillPath: hook.hookDir,
agentPlatform: "openclaw",
dastMode: true,
targetPath: path.resolve(targetPath),
event: eventKey,
};
const safeResult = await invokeHookHarness(hook, safeEvent, safeContext, invocationTimeoutMs);
if (safeResult.timedOut) {
pushHookVulnerability(
findings,
`DAST-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
"high",
hook,
eventKey,
"Hook times out under baseline input",
`Hook execution exceeded ${invocationTimeoutMs}ms for event '${eventKey}' under safe baseline input.`,
);
continue;
}
if (safeResult.parseError) {
pushHookVulnerability(
findings,
`DAST-HARNESS-${slug(`${hook.name}-${eventKey}`)}`,
"medium",
hook,
eventKey,
"Hook harness output invalid",
`Could not parse harness output for event '${eventKey}': ${safeResult.parseError}. stderr: ${safeResult.stderr || "(empty)"}`,
);
continue;
}
const normalizedSafe = normalizeHarnessPayload(safeResult.parsed);
if (!normalizedSafe.ok) {
const reason = normalizedSafe.error || safeResult.stderr || "unknown error";
if (isHarnessCapabilityError(reason)) {
pushHookVulnerability(
findings,
`DAST-COVERAGE-${slug(`${hook.name}-${eventKey}`)}`,
"info",
hook,
eventKey,
"Hook not executable in local DAST harness",
`DAST harness could not execute hook for event '${eventKey}' due to runtime capability limits: ${reason}`,
);
} else {
pushHookVulnerability(
findings,
`DAST-CRASH-${slug(`${hook.name}-${eventKey}`)}`,
"high",
hook,
eventKey,
"Hook throws on baseline input",
`Hook execution failed for event '${eventKey}' under safe baseline input: ${reason}`,
);
}
continue;
}
const mutationObserved =
normalizedSafe.coreAfter.type !== safeEvent.type ||
normalizedSafe.coreAfter.action !== safeEvent.action ||
normalizedSafe.coreAfter.sessionKey !== safeEvent.sessionKey;
if (mutationObserved) {
pushHookVulnerability(
findings,
`DAST-MUTATION-${slug(`${hook.name}-${eventKey}`)}`,
"low",
hook,
eventKey,
"Hook mutates core event identity fields",
`Hook changed one or more of type/action/sessionKey for event '${eventKey}'. This can cause routing side effects in OpenClaw hooks.`,
);
}
if (
normalizedSafe.messagesCount > MAX_OUTPUT_MESSAGES ||
normalizedSafe.messagesCharCount > MAX_OUTPUT_CHARS
) {
pushHookVulnerability(
findings,
`DAST-OUTPUT-${slug(`${hook.name}-${eventKey}`)}`,
"medium",
hook,
eventKey,
"Hook output exceeds safe bounds",
`Hook generated ${normalizedSafe.messagesCount} messages and ${normalizedSafe.messagesCharCount} chars for baseline input. Limits: ${MAX_OUTPUT_MESSAGES} messages / ${MAX_OUTPUT_CHARS} chars.`,
);
}
const maliciousFailures = [];
const maliciousTimeouts = [];
for (const payload of MALICIOUS_PAYLOADS) {
const event = buildEvent(eventKey, payload, targetPath);
const context = {
...safeContext,
payloadLength: payload.length,
};
const result = await invokeHookHarness(hook, event, context, invocationTimeoutMs);
if (result.timedOut) {
maliciousTimeouts.push(`len=${payload.length}`);
continue;
}
if (result.parseError) {
maliciousFailures.push(`parse-error(${result.parseError})`);
continue;
}
const normalized = normalizeHarnessPayload(result.parsed);
if (!normalized.ok) {
maliciousFailures.push(normalized.error || "execution-error");
}
if (
normalized.messagesCount > MAX_OUTPUT_MESSAGES ||
normalized.messagesCharCount > MAX_OUTPUT_CHARS
) {
pushHookVulnerability(
findings,
`DAST-OUTPUT-${slug(`${hook.name}-${eventKey}`)}-${payload.length}`,
"medium",
hook,
eventKey,
"Hook output amplification under malicious input",
`Hook generated ${normalized.messagesCount} messages and ${normalized.messagesCharCount} chars for payload length ${payload.length}.`,
);
}
}
if (maliciousTimeouts.length > 0) {
pushHookVulnerability(
findings,
`DAST-MALICIOUS-TIMEOUT-${slug(`${hook.name}-${eventKey}`)}`,
"high",
hook,
eventKey,
"Hook times out on malicious input",
`Hook exceeded ${invocationTimeoutMs}ms for malicious payloads (${maliciousTimeouts.slice(0, 3).join(", ")}${maliciousTimeouts.length > 3 ? `, +${maliciousTimeouts.length - 3} more` : ""}).`,
);
}
if (maliciousFailures.length > 0) {
pushHookVulnerability(
findings,
`DAST-MALICIOUS-CRASH-${slug(`${hook.name}-${eventKey}`)}`,
"high",
hook,
eventKey,
"Hook crashes on malicious input",
`Hook raised unhandled errors for malicious payloads. Sample errors: ${maliciousFailures.slice(0, 3).join(" | ")}${maliciousFailures.length > 3 ? ` (+${maliciousFailures.length - 3} more)` : ""}`,
);
}
}
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);
}
}
export { MALICIOUS_PAYLOADS };
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
@@ -0,0 +1,291 @@
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`);
}
+288
View File
@@ -0,0 +1,288 @@
#!/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
+306
View File
@@ -0,0 +1,306 @@
#!/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();
}
+325
View File
@@ -0,0 +1,325 @@
#!/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();
}
+126
View File
@@ -0,0 +1,126 @@
#!/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);
}
+147
View File
@@ -0,0 +1,147 @@
{
"name": "clawsec-scanner",
"version": "0.0.2",
"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 DAST hook execution testing 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": "Dynamic analysis harness executing OpenClaw hook handlers with malicious-input and timeout checks"
},
{
"path": "scripts/dast_hook_executor.mjs",
"required": true,
"description": "Isolated hook execution helper used by DAST for real OpenClaw harness testing"
},
{
"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"
},
{
"path": "test/dependency_scanner.test.mjs",
"required": false,
"description": "Unit tests for dependency scanning (npm audit, pip-audit)"
},
{
"path": "test/cve_integration.test.mjs",
"required": false,
"description": "Integration tests for CVE database API queries"
},
{
"path": "test/sast_engine.test.mjs",
"required": false,
"description": "Unit tests for SAST analysis (Semgrep, Bandit)"
},
{
"path": "test/dast_harness.test.mjs",
"required": false,
"description": "DAST harness tests for real hook execution and malicious-input failure detection"
}
]
},
"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"
]
}
}
+571
View File
@@ -0,0 +1,571 @@
#!/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();
}
@@ -0,0 +1,250 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
pass,
fail,
report,
exitWithResults,
createTempDir,
} from "./lib/test_harness.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SKILL_ROOT = path.resolve(__dirname, "..");
const DAST_SCRIPT = path.join(SKILL_ROOT, "scripts", "dast_runner.mjs");
/**
* @param {string} targetPath
* @param {number} timeoutMs
* @param {Record<string, string>} envOverrides
* @returns {Promise<{code: number, stdout: string, stderr: string, report: any}>}
*/
async function runDast(targetPath, timeoutMs = 3000, envOverrides = {}) {
return new Promise((resolve, reject) => {
const proc = spawn(
"node",
[DAST_SCRIPT, "--target", targetPath, "--format", "json", "--timeout", String(timeoutMs)],
{
cwd: SKILL_ROOT,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...envOverrides,
},
},
);
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
proc.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
proc.on("error", reject);
proc.on("close", (code) => {
try {
const parsed = JSON.parse(stdout.trim());
resolve({
code: code ?? 1,
stdout,
stderr,
report: parsed,
});
} catch (error) {
reject(new Error(`Failed to parse DAST JSON output: ${String(error)}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`));
}
});
});
}
/**
* @param {string} hookDir
* @param {string} eventsLiteral
* @param {string} handlerSource
* @param {string} [handlerFile]
* @returns {Promise<void>}
*/
async function writeHookFixture(hookDir, eventsLiteral, handlerSource, handlerFile = "handler.js") {
await fs.mkdir(hookDir, { recursive: true });
const hookMd = `---
name: ${path.basename(hookDir)}
description: fixture hook
metadata: { "openclaw": { "events": [${eventsLiteral}] } }
---
# Fixture Hook
`;
await fs.writeFile(path.join(hookDir, "HOOK.md"), hookMd, "utf8");
await fs.writeFile(path.join(hookDir, handlerFile), handlerSource, "utf8");
}
async function testSafeHookExecutesAndDoesNotReportMisleadingHigh() {
const testName = "DAST harness: executes real hook and reports no misleading high findings";
const tmp = await createTempDir();
try {
const targetPath = path.join(tmp.path, "skill");
const hookDir = path.join(targetPath, "hooks", "safe-hook");
const markerFile = path.join(hookDir, "executed.marker");
await writeHookFixture(
hookDir,
'"command:new"',
`import fs from "node:fs/promises";
import path from "node:path";
const handler = async (event, context) => {
const marker = path.join(path.dirname(new URL(import.meta.url).pathname), "executed.marker");
await fs.writeFile(marker, String(context?.event || "unknown"), "utf8");
if (!Array.isArray(event.messages)) {
event.messages = [];
}
event.messages.push("hook executed");
};
export default handler;
`,
);
const result = await runDast(targetPath, 2500);
const markerExists = await fs
.access(markerFile)
.then(() => true)
.catch(() => false);
const cleanSummary =
result.report?.summary?.critical === 0
&& result.report?.summary?.high === 0
&& result.report?.summary?.medium === 0
&& result.report?.summary?.low === 0
&& result.report?.summary?.info === 0;
if (result.code === 0 && markerExists && cleanSummary) {
pass(testName);
} else {
fail(
testName,
`Expected exit=0, markerExists=true, clean summary. Got exit=${result.code}, markerExists=${markerExists}, summary=${JSON.stringify(result.report?.summary)} stderr=${result.stderr}`,
);
}
} catch (error) {
fail(testName, error);
} finally {
await tmp.cleanup();
}
}
async function testMaliciousCrashProducesHighFinding() {
const testName = "DAST harness: malicious input crash is reported as high";
const tmp = await createTempDir();
try {
const targetPath = path.join(tmp.path, "skill");
const hookDir = path.join(targetPath, "hooks", "crashy-hook");
await writeHookFixture(
hookDir,
'"message:preprocessed"',
`const handler = async (event) => {
const payload = String(event?.context?.content || "");
if (payload.includes("<script>")) {
throw new Error("Unhandled payload path");
}
};
export default handler;
`,
);
const result = await runDast(targetPath, 2500);
const hasHigh = Number(result.report?.summary?.high || 0) > 0;
const hasCrashFinding = Array.isArray(result.report?.vulnerabilities)
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-MALICIOUS-CRASH"));
if (result.code === 1 && hasHigh && hasCrashFinding) {
pass(testName);
} else {
fail(
testName,
`Expected exit=1 and malicious crash high finding. Got exit=${result.code}, summary=${JSON.stringify(result.report?.summary)}, findings=${JSON.stringify(result.report?.vulnerabilities || [])}`,
);
}
} catch (error) {
fail(testName, error);
} finally {
await tmp.cleanup();
}
}
async function testMissingTypeScriptCompilerIsCoverageInfo() {
const testName = "DAST harness: missing TypeScript compiler reports coverage info, not high";
const tmp = await createTempDir();
try {
const targetPath = path.join(tmp.path, "skill");
const hookDir = path.join(targetPath, "hooks", "ts-hook");
await writeHookFixture(
hookDir,
'"command:new"',
`type Ctx = { dastMode?: boolean };
const handler = async (_event: unknown, _context: Ctx): Promise<void> => {
return;
};
export default handler;
`,
"handler.ts",
);
const result = await runDast(
targetPath,
2500,
{ CLAWSEC_DAST_DISABLE_TYPESCRIPT: "1" },
);
const noHigh = Number(result.report?.summary?.high || 0) === 0
&& Number(result.report?.summary?.critical || 0) === 0;
const hasCoverageInfo = Array.isArray(result.report?.vulnerabilities)
&& result.report.vulnerabilities.some((v) => String(v.id || "").includes("DAST-COVERAGE"));
const hasInfoCount = Number(result.report?.summary?.info || 0) > 0;
if (result.code === 0 && noHigh && hasCoverageInfo && hasInfoCount) {
pass(testName);
} else {
fail(
testName,
`Expected coverage info only (no high/critical). Got exit=${result.code}, summary=${JSON.stringify(result.report?.summary)}, findings=${JSON.stringify(result.report?.vulnerabilities || [])}`,
);
}
} catch (error) {
fail(testName, error);
} finally {
await tmp.cleanup();
}
}
async function main() {
await testSafeHookExecutesAndDoesNotReportMisleadingHigh();
await testMaliciousCrashProducesHighFinding();
await testMissingTypeScriptCompilerIsCoverageInfo();
report();
exitWithResults();
}
await main();
+597
View File
@@ -0,0 +1,597 @@
#!/usr/bin/env node
/**
* Dependency scanner tests for clawsec-scanner.
*
* Tests cover:
* - Utility functions (normalizeSeverity, safeJsonParse, commandExists)
* - Report generation and formatting
* - Argument parsing
* - Integration with temp directory setup
*
* Run: node skills/clawsec-scanner/test/dependency_scanner.test.mjs
*/
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { pass, fail, report, exitWithResults, createTempDir } from "./lib/test_harness.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIB_PATH = path.resolve(__dirname, "..", "lib");
// Dynamic import to ensure we test the actual modules
const { normalizeSeverity, safeJsonParse, getTimestamp, generateUuid, commandExists } =
await import(`${LIB_PATH}/utils.mjs`);
const { generateReport, formatReportJson, formatReportText } = await import(
`${LIB_PATH}/report.mjs`
);
// -----------------------------------------------------------------------------
// Test: normalizeSeverity - critical variations
// -----------------------------------------------------------------------------
async function testNormalizeSeverity_Critical() {
const testName = "normalizeSeverity: recognizes critical";
try {
const test1 = normalizeSeverity("critical");
const test2 = normalizeSeverity("CRITICAL");
const test3 = normalizeSeverity(" Critical ");
if (test1 === "critical" && test2 === "critical" && test3 === "critical") {
pass(testName);
} else {
fail(testName, `Expected 'critical', got ${test1}, ${test2}, ${test3}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: normalizeSeverity - high variations
// -----------------------------------------------------------------------------
async function testNormalizeSeverity_High() {
const testName = "normalizeSeverity: recognizes high";
try {
const test1 = normalizeSeverity("high");
const test2 = normalizeSeverity("HIGH");
if (test1 === "high" && test2 === "high") {
pass(testName);
} else {
fail(testName, `Expected 'high', got ${test1}, ${test2}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: normalizeSeverity - medium variations (moderate, medium)
// -----------------------------------------------------------------------------
async function testNormalizeSeverity_Medium() {
const testName = "normalizeSeverity: recognizes medium/moderate";
try {
const test1 = normalizeSeverity("medium");
const test2 = normalizeSeverity("moderate");
const test3 = normalizeSeverity("MODERATE");
if (test1 === "medium" && test2 === "medium" && test3 === "medium") {
pass(testName);
} else {
fail(testName, `Expected 'medium', got ${test1}, ${test2}, ${test3}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: normalizeSeverity - low variations
// -----------------------------------------------------------------------------
async function testNormalizeSeverity_Low() {
const testName = "normalizeSeverity: recognizes low";
try {
const test1 = normalizeSeverity("low");
const test2 = normalizeSeverity("LOW");
if (test1 === "low" && test2 === "low") {
pass(testName);
} else {
fail(testName, `Expected 'low', got ${test1}, ${test2}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: normalizeSeverity - defaults to info for unknown
// -----------------------------------------------------------------------------
async function testNormalizeSeverity_Unknown() {
const testName = "normalizeSeverity: defaults to info for unknown";
try {
const test1 = normalizeSeverity("unknown");
const test2 = normalizeSeverity("");
const test3 = normalizeSeverity("garbage");
if (test1 === "info" && test2 === "info" && test3 === "info") {
pass(testName);
} else {
fail(testName, `Expected 'info', got ${test1}, ${test2}, ${test3}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: safeJsonParse - valid JSON
// -----------------------------------------------------------------------------
async function testSafeJsonParse_Valid() {
const testName = "safeJsonParse: parses valid JSON";
try {
const json = '{"foo": "bar", "num": 42}';
const result = safeJsonParse(json);
if (
result &&
typeof result === "object" &&
result.foo === "bar" &&
result.num === 42
) {
pass(testName);
} else {
fail(testName, `Unexpected result: ${JSON.stringify(result)}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: safeJsonParse - invalid JSON returns fallback
// -----------------------------------------------------------------------------
async function testSafeJsonParse_Invalid() {
const testName = "safeJsonParse: returns fallback for invalid JSON";
try {
const invalid = "{not valid json}";
const fallback = { error: true };
const result = safeJsonParse(invalid, { fallback });
if (result && result.error === true) {
pass(testName);
} else {
fail(testName, `Expected fallback object, got ${JSON.stringify(result)}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: safeJsonParse - empty string returns fallback
// -----------------------------------------------------------------------------
async function testSafeJsonParse_Empty() {
const testName = "safeJsonParse: returns fallback for empty string";
try {
const result = safeJsonParse("", { fallback: null });
if (result === null) {
pass(testName);
} else {
fail(testName, `Expected null, got ${JSON.stringify(result)}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: getTimestamp - returns ISO 8601 format
// -----------------------------------------------------------------------------
async function testGetTimestamp() {
const testName = "getTimestamp: returns ISO 8601 format";
try {
const timestamp = getTimestamp();
const iso8601Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
if (iso8601Pattern.test(timestamp)) {
pass(testName);
} else {
fail(testName, `Expected ISO 8601 format, got ${timestamp}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: generateUuid - returns valid UUID v4 format
// -----------------------------------------------------------------------------
async function testGenerateUuid() {
const testName = "generateUuid: returns valid UUID v4 format";
try {
const uuid = generateUuid();
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (uuidPattern.test(uuid)) {
pass(testName);
} else {
fail(testName, `Expected UUID v4 format, got ${uuid}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: generateUuid - generates unique IDs
// -----------------------------------------------------------------------------
async function testGenerateUuid_Unique() {
const testName = "generateUuid: generates unique IDs";
try {
const uuid1 = generateUuid();
const uuid2 = generateUuid();
const uuid3 = generateUuid();
if (uuid1 !== uuid2 && uuid2 !== uuid3 && uuid1 !== uuid3) {
pass(testName);
} else {
fail(testName, `Expected unique UUIDs, got ${uuid1}, ${uuid2}, ${uuid3}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: generateReport - empty vulnerabilities
// -----------------------------------------------------------------------------
async function testGenerateReport_Empty() {
const testName = "generateReport: handles empty vulnerabilities";
try {
const report = generateReport([], "/test/path");
if (
report &&
report.vulnerabilities.length === 0 &&
report.summary.critical === 0 &&
report.summary.high === 0 &&
report.summary.medium === 0 &&
report.summary.low === 0 &&
report.summary.info === 0 &&
report.target === "/test/path"
) {
pass(testName);
} else {
fail(testName, `Unexpected report structure: ${JSON.stringify(report)}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: generateReport - counts vulnerabilities by severity
// -----------------------------------------------------------------------------
async function testGenerateReport_Counts() {
const testName = "generateReport: counts vulnerabilities by severity";
try {
const vulnerabilities = [
{
id: "TEST-001",
source: "test",
severity: "critical",
package: "test-pkg",
version: "1.0.0",
fixed_version: "1.1.0",
title: "Test Critical",
description: "Test",
references: [],
discovered_at: "2026-01-01T00:00:00.000Z",
},
{
id: "TEST-002",
source: "test",
severity: "high",
package: "test-pkg",
version: "1.0.0",
fixed_version: "1.1.0",
title: "Test High",
description: "Test",
references: [],
discovered_at: "2026-01-01T00:00:00.000Z",
},
{
id: "TEST-003",
source: "test",
severity: "high",
package: "test-pkg-2",
version: "2.0.0",
fixed_version: "2.1.0",
title: "Test High 2",
description: "Test",
references: [],
discovered_at: "2026-01-01T00:00:00.000Z",
},
{
id: "TEST-004",
source: "test",
severity: "medium",
package: "test-pkg-3",
version: "3.0.0",
fixed_version: "3.1.0",
title: "Test Medium",
description: "Test",
references: [],
discovered_at: "2026-01-01T00:00:00.000Z",
},
];
const report = generateReport(vulnerabilities, ".");
if (
report.summary.critical === 1 &&
report.summary.high === 2 &&
report.summary.medium === 1 &&
report.summary.low === 0 &&
report.summary.info === 0 &&
report.vulnerabilities.length === 4
) {
pass(testName);
} else {
fail(testName, `Unexpected counts: ${JSON.stringify(report.summary)}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: formatReportJson - produces valid JSON
// -----------------------------------------------------------------------------
async function testFormatReportJson() {
const testName = "formatReportJson: produces valid JSON";
try {
const report = generateReport([], "/test/path");
const jsonString = formatReportJson(report);
const parsed = JSON.parse(jsonString);
if (parsed && parsed.target === "/test/path" && Array.isArray(parsed.vulnerabilities)) {
pass(testName);
} else {
fail(testName, `Invalid JSON structure: ${jsonString}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: formatReportText - produces text output
// -----------------------------------------------------------------------------
async function testFormatReportText() {
const testName = "formatReportText: produces text output";
try {
const report = generateReport([], "/test/path");
const text = formatReportText(report);
if (
text.includes("VULNERABILITY SCAN REPORT") &&
text.includes("Target: /test/path") &&
text.includes("No vulnerabilities detected")
) {
pass(testName);
} else {
fail(testName, "Missing expected text output sections");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: formatReportText - includes vulnerability details
// -----------------------------------------------------------------------------
async function testFormatReportText_WithVulnerabilities() {
const testName = "formatReportText: includes vulnerability details";
try {
const vulnerabilities = [
{
id: "CVE-2026-1234",
source: "npm-audit",
severity: "high",
package: "test-package",
version: "1.0.0",
fixed_version: "1.1.0",
title: "Test Vulnerability",
description: "This is a test vulnerability description",
references: ["https://example.com/cve-2026-1234"],
discovered_at: "2026-01-01T00:00:00.000Z",
},
];
const report = generateReport(vulnerabilities, ".");
const text = formatReportText(report);
if (
text.includes("CVE-2026-1234") &&
text.includes("test-package") &&
text.includes("1.0.0") &&
text.includes("1.1.0") &&
text.includes("Test Vulnerability") &&
text.includes("HIGH")
) {
pass(testName);
} else {
fail(testName, "Missing expected vulnerability details in text output");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: commandExists - detects existing command
// -----------------------------------------------------------------------------
async function testCommandExists_Found() {
const testName = "commandExists: detects existing command (node)";
try {
// 'node' should always exist in the test environment
const result = await commandExists("node");
if (result === true) {
pass(testName);
} else {
fail(testName, "Expected true for 'node' command");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: commandExists - returns false for non-existent command
// -----------------------------------------------------------------------------
async function testCommandExists_NotFound() {
const testName = "commandExists: returns false for non-existent command";
try {
// Use a command that definitely doesn't exist
const result = await commandExists("definitely-not-a-real-command-12345");
if (result === false) {
pass(testName);
} else {
fail(testName, "Expected false for non-existent command");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Report structure - has required fields
// -----------------------------------------------------------------------------
async function testReportStructure() {
const testName = "Report structure: has all required fields";
try {
const report = generateReport([], ".");
const hasAllFields =
"scan_id" in report &&
"timestamp" in report &&
"target" in report &&
"vulnerabilities" in report &&
"summary" in report &&
"critical" in report.summary &&
"high" in report.summary &&
"medium" in report.summary &&
"low" in report.summary &&
"info" in report.summary;
if (hasAllFields) {
pass(testName);
} else {
fail(testName, `Missing required fields in report: ${JSON.stringify(report)}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Temp directory creation
// -----------------------------------------------------------------------------
async function testTempDirCreation() {
const testName = "createTempDir: creates and cleans up temp directory";
try {
const { path: tmpPath, cleanup } = await createTempDir();
// Verify directory exists
const stat = await fs.stat(tmpPath);
if (!stat.isDirectory()) {
fail(testName, "Created path is not a directory");
return;
}
// Create a test file
const testFilePath = path.join(tmpPath, "test.txt");
await fs.writeFile(testFilePath, "test content");
// Verify file exists
const fileExists = await fs
.access(testFilePath)
.then(() => true)
.catch(() => false);
if (!fileExists) {
fail(testName, "Test file was not created");
return;
}
// Cleanup
await cleanup();
// Verify cleanup
const dirExists = await fs
.access(tmpPath)
.then(() => true)
.catch(() => false);
if (dirExists) {
fail(testName, "Temp directory was not cleaned up");
} else {
pass(testName);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
async function main() {
console.log("Running dependency scanner tests...\n");
// Utility function tests
await testNormalizeSeverity_Critical();
await testNormalizeSeverity_High();
await testNormalizeSeverity_Medium();
await testNormalizeSeverity_Low();
await testNormalizeSeverity_Unknown();
await testSafeJsonParse_Valid();
await testSafeJsonParse_Invalid();
await testSafeJsonParse_Empty();
await testGetTimestamp();
await testGenerateUuid();
await testGenerateUuid_Unique();
await testCommandExists_Found();
await testCommandExists_NotFound();
// Report generation tests
await testGenerateReport_Empty();
await testGenerateReport_Counts();
await testReportStructure();
// Report formatting tests
await testFormatReportJson();
await testFormatReportText();
await testFormatReportText_WithVulnerabilities();
// Infrastructure tests
await testTempDirCreation();
// Final report
report();
exitWithResults();
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
@@ -0,0 +1,101 @@
/**
* 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;
}
}
}
@@ -0,0 +1,248 @@
#!/usr/bin/env node
/**
* Regression tests for Baz review findings on PR #101.
*
* These tests enforce:
* - execCommand supports cwd and runs tools in the target directory
* - scan_dependencies chooses pip-audit invocation correctly when requirements.txt is absent
* - runner.sh preserves DAST findings even when dast_runner exits non-zero
*/
import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { pass, fail, report, exitWithResults, createTempDir } from "./lib/test_harness.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SKILL_ROOT = path.resolve(__dirname, "..");
const SCRIPTS_DIR = path.join(SKILL_ROOT, "scripts");
const { execCommand } = await import(path.join(SKILL_ROOT, "lib", "utils.mjs"));
/**
* @param {string} cmd
* @param {string[]} args
* @param {{cwd?: string, env?: NodeJS.ProcessEnv}} [options]
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
*/
async function runProcess(cmd, args, options = {}) {
return new Promise((resolve) => {
const proc = spawn(cmd, args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
proc.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
proc.on("close", (code) => {
resolve({ code: code ?? 1, stdout, stderr });
});
});
}
/**
* @param {string} filePath
* @param {string} content
*/
async function writeExecutable(filePath, content) {
await fs.writeFile(filePath, content, "utf8");
await fs.chmod(filePath, 0o755);
}
async function testExecCommandRespectsCwd() {
const testName = "execCommand: respects cwd option";
const tmp = await createTempDir();
try {
const result = await execCommand("node", ["-e", "process.stdout.write(process.cwd())"], {
cwd: tmp.path,
});
const expectedPath = await fs.realpath(tmp.path);
const actualPath = await fs.realpath(result.stdout.trim());
if (actualPath === expectedPath) {
pass(testName);
} else {
fail(testName, `Expected cwd ${expectedPath}, got ${actualPath}`);
}
} catch (error) {
fail(testName, error);
} finally {
await tmp.cleanup();
}
}
async function testScanDependenciesUsesTargetCwdAndSmartPipArgs() {
const testName = "scan_dependencies: runs npm in target cwd and avoids -r when requirements.txt missing";
const tmp = await createTempDir();
try {
const targetDir = path.join(tmp.path, "target");
const binDir = path.join(tmp.path, "bin");
const npmLogPath = path.join(tmp.path, "npm.log");
const pipLogPath = path.join(tmp.path, "pip.log");
await fs.mkdir(targetDir, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await fs.writeFile(path.join(targetDir, "package-lock.json"), "{}\n", "utf8");
await fs.writeFile(path.join(targetDir, "pyproject.toml"), "[project]\nname='demo'\nversion='0.1.0'\n", "utf8");
await writeExecutable(
path.join(binDir, "npm"),
`#!/usr/bin/env node
const fs = require("node:fs");
const logPath = process.env.CLAWSEC_TEST_NPM_LOG;
fs.appendFileSync(logPath, JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }) + "\\n");
process.stdout.write(JSON.stringify({ vulnerabilities: {} }));
`,
);
await writeExecutable(
path.join(binDir, "pip-audit"),
`#!/usr/bin/env node
const fs = require("node:fs");
const logPath = process.env.CLAWSEC_TEST_PIP_LOG;
fs.appendFileSync(logPath, JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }) + "\\n");
process.stdout.write(JSON.stringify({ dependencies: [] }));
`,
);
const env = {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
CLAWSEC_TEST_NPM_LOG: npmLogPath,
CLAWSEC_TEST_PIP_LOG: pipLogPath,
};
const result = await runProcess(
"node",
[path.join(SCRIPTS_DIR, "scan_dependencies.mjs"), "--target", targetDir, "--format", "json"],
{ cwd: SKILL_ROOT, env },
);
if (result.code !== 0) {
fail(testName, `scan_dependencies exited ${result.code}: ${result.stderr}`);
return;
}
const npmLog = JSON.parse((await fs.readFile(npmLogPath, "utf8")).trim());
const pipLog = JSON.parse((await fs.readFile(pipLogPath, "utf8")).trim());
const expectedTargetPath = await fs.realpath(targetDir);
const actualNpmCwd = await fs.realpath(npmLog.cwd);
const npmCwdOk = actualNpmCwd === expectedTargetPath;
const pipArgsOk = !pipLog.args.includes("-r");
if (npmCwdOk && pipArgsOk) {
pass(testName);
} else {
fail(
testName,
`npm cwd=${actualNpmCwd}, expected=${expectedTargetPath}; pip args=${JSON.stringify(pipLog.args)}`,
);
}
} catch (error) {
fail(testName, error);
} finally {
await tmp.cleanup();
}
}
async function testRunnerPreservesDastReportOnNonZeroExit() {
const testName = "runner.sh: preserves DAST findings when dast_runner exits 1";
const tmp = await createTempDir();
try {
const targetDir = path.join(tmp.path, "target");
const binDir = path.join(tmp.path, "bin");
await fs.mkdir(targetDir, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await writeExecutable(
path.join(binDir, "node"),
`#!/usr/bin/env bash
set -euo pipefail
script="\${1:-}"
target="."
while [[ $# -gt 0 ]]; do
if [[ "$1" == "--target" ]]; then
target="\${2:-.}"
break
fi
shift
done
if [[ "$script" == *"scan_dependencies.mjs" ]] || [[ "$script" == *"sast_analyzer.mjs" ]]; then
cat <<JSON
{"scan_id":"test-scan","timestamp":"2026-03-09T00:00:00.000Z","target":"$target","vulnerabilities":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0}}
JSON
exit 0
fi
if [[ "$script" == *"dast_runner.mjs" ]]; then
cat <<JSON
{"scan_id":"test-scan","timestamp":"2026-03-09T00:00:00.000Z","target":"$target","vulnerabilities":[{"id":"DAST-001","source":"dast","severity":"high","package":"N/A","version":"N/A","title":"DAST finding","description":"Synthetic high severity finding","references":[],"discovered_at":"2026-03-09T00:00:00.000Z"}],"summary":{"critical":0,"high":1,"medium":0,"low":0,"info":0}}
JSON
exit 1
fi
echo "Unexpected node invocation: $*" >&2
exit 2
`,
);
const env = {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
};
const result = await runProcess(
"bash",
[path.join(SCRIPTS_DIR, "runner.sh"), "--target", targetDir, "--format", "json"],
{ cwd: SKILL_ROOT, env },
);
if (result.code !== 0) {
fail(testName, `runner.sh exited ${result.code}: ${result.stderr}`);
return;
}
const merged = JSON.parse(result.stdout.trim());
const hasDastFinding = Array.isArray(merged.vulnerabilities)
&& merged.vulnerabilities.some((v) => v.id === "DAST-001" && v.source === "dast" && v.severity === "high");
if (hasDastFinding && merged.summary.high >= 1) {
pass(testName);
} else {
fail(testName, `Expected DAST high finding to be preserved. Output: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
} finally {
await tmp.cleanup();
}
}
async function main() {
await testExecCommandRespectsCwd();
await testScanDependenciesUsesTargetCwdAndSmartPipArgs();
await testRunnerPreservesDastReportOnNonZeroExit();
report();
exitWithResults();
}
await main();
+570
View File
@@ -0,0 +1,570 @@
#!/usr/bin/env node
/**
* SAST engine tests for clawsec-scanner.
*
* Tests cover:
* - Semgrep output parsing and normalization
* - Bandit output parsing and normalization
* - File existence checking
* - Vulnerability data structure validation
* - Error handling for malformed tool outputs
*
* Run: node skills/clawsec-scanner/test/sast_engine.test.mjs
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import { pass, fail, report, exitWithResults } from "./lib/test_harness.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIB_PATH = path.resolve(__dirname, "..", "lib");
// Dynamic import to ensure we test the actual modules
const { normalizeSeverity, safeJsonParse, getTimestamp } = await import(`${LIB_PATH}/utils.mjs`);
// -----------------------------------------------------------------------------
// Test: Parse valid Semgrep JSON output
// -----------------------------------------------------------------------------
async function testParseSemgrepOutput_Valid() {
const testName = "SAST: parse valid Semgrep JSON output";
try {
const semgrepOutput = JSON.stringify({
results: [
{
check_id: "javascript.lang.security.audit.unsafe-regex.unsafe-regex",
path: "test/file.js",
start: { line: 42 },
extra: {
message: "Potential ReDoS vulnerability detected",
severity: "WARNING",
metadata: {
references: ["https://owasp.org/redos"],
source: "semgrep-rules",
},
},
},
],
});
const parsed = safeJsonParse(semgrepOutput, {
fallback: { results: [] },
label: "semgrep output",
});
if (
parsed &&
parsed.results &&
parsed.results.length === 1 &&
parsed.results[0].check_id === "javascript.lang.security.audit.unsafe-regex.unsafe-regex"
) {
pass(testName);
} else {
fail(testName, "Failed to parse valid Semgrep output correctly");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Parse Semgrep output with missing fields
// -----------------------------------------------------------------------------
async function testParseSemgrepOutput_MissingFields() {
const testName = "SAST: handle Semgrep output with missing fields";
try {
const semgrepOutput = JSON.stringify({
results: [
{
// Missing check_id, path, extra
start: { line: 10 },
},
],
});
const parsed = safeJsonParse(semgrepOutput, {
fallback: { results: [] },
label: "semgrep output",
});
// Should parse successfully even with missing fields
if (parsed && parsed.results && parsed.results.length === 1) {
pass(testName);
} else {
fail(testName, "Failed to handle Semgrep output with missing fields");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Parse empty Semgrep results
// -----------------------------------------------------------------------------
async function testParseSemgrepOutput_Empty() {
const testName = "SAST: handle empty Semgrep results";
try {
const semgrepOutput = JSON.stringify({ results: [] });
const parsed = safeJsonParse(semgrepOutput, {
fallback: { results: [] },
label: "semgrep output",
});
if (parsed && Array.isArray(parsed.results) && parsed.results.length === 0) {
pass(testName);
} else {
fail(testName, "Failed to handle empty Semgrep results");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Parse malformed Semgrep JSON
// -----------------------------------------------------------------------------
async function testParseSemgrepOutput_Malformed() {
const testName = "SAST: handle malformed Semgrep JSON gracefully";
try {
const malformedJson = "{ results: [{ invalid json }] }";
const parsed = safeJsonParse(malformedJson, {
fallback: { results: [] },
label: "semgrep output",
});
// Should fall back to default value
if (parsed && Array.isArray(parsed.results) && parsed.results.length === 0) {
pass(testName);
} else {
fail(testName, "Failed to use fallback for malformed JSON");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Parse valid Bandit JSON output
// -----------------------------------------------------------------------------
async function testParseBanditOutput_Valid() {
const testName = "SAST: parse valid Bandit JSON output";
try {
const banditOutput = JSON.stringify({
results: [
{
test_id: "B201",
filename: "/path/to/file.py",
line_number: 15,
issue_text: "A possibly insecure use of pickle detected.",
issue_severity: "HIGH",
issue_confidence: "HIGH",
},
],
});
const parsed = safeJsonParse(banditOutput, {
fallback: { results: [] },
label: "bandit output",
});
if (
parsed &&
parsed.results &&
parsed.results.length === 1 &&
parsed.results[0].test_id === "B201"
) {
pass(testName);
} else {
fail(testName, "Failed to parse valid Bandit output correctly");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Parse Bandit output with missing fields
// -----------------------------------------------------------------------------
async function testParseBanditOutput_MissingFields() {
const testName = "SAST: handle Bandit output with missing fields";
try {
const banditOutput = JSON.stringify({
results: [
{
// Missing test_id, issue_text, etc.
filename: "/path/to/file.py",
},
],
});
const parsed = safeJsonParse(banditOutput, {
fallback: { results: [] },
label: "bandit output",
});
// Should parse successfully even with missing fields
if (parsed && parsed.results && parsed.results.length === 1) {
pass(testName);
} else {
fail(testName, "Failed to handle Bandit output with missing fields");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Parse empty Bandit results
// -----------------------------------------------------------------------------
async function testParseBanditOutput_Empty() {
const testName = "SAST: handle empty Bandit results";
try {
const banditOutput = JSON.stringify({ results: [] });
const parsed = safeJsonParse(banditOutput, {
fallback: { results: [] },
label: "bandit output",
});
if (parsed && Array.isArray(parsed.results) && parsed.results.length === 0) {
pass(testName);
} else {
fail(testName, "Failed to handle empty Bandit results");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Normalize Semgrep severity levels
// -----------------------------------------------------------------------------
async function testNormalizeSeverity_Semgrep() {
const testName = "SAST: normalize Semgrep severity levels";
try {
const errorLevel = normalizeSeverity("ERROR");
const warningLevel = normalizeSeverity("WARNING");
const infoLevel = normalizeSeverity("INFO");
// Semgrep uses ERROR, WARNING, INFO
// normalizeSeverity uses substring matching, so these map to 'info' (default)
// since they don't contain 'critical', 'high', 'medium', 'moderate', or 'low'
if (errorLevel === "info" && warningLevel === "info" && infoLevel === "info") {
pass(testName);
} else {
fail(
testName,
`Unexpected normalization: ERROR=${errorLevel}, WARNING=${warningLevel}, INFO=${infoLevel}`,
);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Normalize Bandit severity levels
// -----------------------------------------------------------------------------
async function testNormalizeSeverity_Bandit() {
const testName = "SAST: normalize Bandit severity levels";
try {
const highLevel = normalizeSeverity("HIGH");
const mediumLevel = normalizeSeverity("MEDIUM");
const lowLevel = normalizeSeverity("LOW");
if (
(highLevel === "high" || highLevel === "critical") &&
mediumLevel === "medium" &&
lowLevel === "low"
) {
pass(testName);
} else {
fail(
testName,
`Unexpected normalization: HIGH=${highLevel}, MEDIUM=${mediumLevel}, LOW=${lowLevel}`,
);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Validate vulnerability data structure from Semgrep
// -----------------------------------------------------------------------------
async function testVulnerabilityStructure_Semgrep() {
const testName = "SAST: validate Semgrep vulnerability data structure";
try {
// Simulate vulnerability object created from Semgrep output
const vuln = {
id: "javascript.lang.security.audit.unsafe-regex.unsafe-regex",
source: "sast",
severity: normalizeSeverity("WARNING"),
package: "file.js",
version: "test/file.js:42",
fixed_version: "",
title: "Potential ReDoS vulnerability detected",
description: "Potential ReDoS vulnerability detected",
references: ["https://owasp.org/redos", "semgrep-rules"],
discovered_at: getTimestamp(),
};
// Validate required fields
const hasRequiredFields =
typeof vuln.id === "string" &&
vuln.id.length > 0 &&
vuln.source === "sast" &&
typeof vuln.severity === "string" &&
typeof vuln.package === "string" &&
typeof vuln.discovered_at === "string" &&
Array.isArray(vuln.references);
if (hasRequiredFields) {
pass(testName);
} else {
fail(testName, "Vulnerability object missing required fields");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Validate vulnerability data structure from Bandit
// -----------------------------------------------------------------------------
async function testVulnerabilityStructure_Bandit() {
const testName = "SAST: validate Bandit vulnerability data structure";
try {
// Simulate vulnerability object created from Bandit output
const vuln = {
id: "B201",
source: "sast",
severity: normalizeSeverity("HIGH"),
package: "file.py",
version: "/path/to/file.py:15",
fixed_version: "",
title: "A possibly insecure use of pickle detected.",
description: "A possibly insecure use of pickle detected.",
references: ["https://bandit.readthedocs.io/en/latest/plugins/b201.html"],
discovered_at: getTimestamp(),
};
// Validate required fields
const hasRequiredFields =
typeof vuln.id === "string" &&
vuln.id.length > 0 &&
vuln.source === "sast" &&
typeof vuln.severity === "string" &&
typeof vuln.package === "string" &&
typeof vuln.discovered_at === "string" &&
Array.isArray(vuln.references) &&
vuln.references.length > 0;
if (hasRequiredFields) {
pass(testName);
} else {
fail(testName, "Vulnerability object missing required fields");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Timestamp format validation
// -----------------------------------------------------------------------------
async function testTimestampFormat() {
const testName = "SAST: validate timestamp format";
try {
const timestamp = getTimestamp();
// Should be ISO 8601 format
const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
if (iso8601Regex.test(timestamp)) {
pass(testName);
} else {
fail(testName, `Invalid timestamp format: ${timestamp}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Handle Semgrep results with metadata variations
// -----------------------------------------------------------------------------
async function testSemgrepMetadata_Variations() {
const testName = "SAST: handle Semgrep metadata variations";
try {
// Test with missing metadata
const output1 = JSON.stringify({
results: [
{
check_id: "test-rule",
path: "test.js",
extra: {
message: "Test message",
severity: "ERROR",
},
},
],
});
// Test with metadata but no references
const output2 = JSON.stringify({
results: [
{
check_id: "test-rule",
path: "test.js",
extra: {
message: "Test message",
severity: "ERROR",
metadata: {
source: "custom-rule",
},
},
},
],
});
const parsed1 = safeJsonParse(output1, {
fallback: { results: [] },
label: "semgrep output",
});
const parsed2 = safeJsonParse(output2, {
fallback: { results: [] },
label: "semgrep output",
});
if (
parsed1 &&
parsed1.results &&
parsed1.results.length === 1 &&
parsed2 &&
parsed2.results &&
parsed2.results.length === 1
) {
pass(testName);
} else {
fail(testName, "Failed to handle metadata variations");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Validate reference URL formats
// -----------------------------------------------------------------------------
async function testReferenceUrlFormats() {
const testName = "SAST: validate reference URL formats";
try {
// Bandit reference format
const testId = "B201";
const banditRef = `https://bandit.readthedocs.io/en/latest/plugins/${testId.toLowerCase().replace(/_/g, "-")}.html`;
// Should follow expected pattern
const expectedRef = "https://bandit.readthedocs.io/en/latest/plugins/b201.html";
if (banditRef === expectedRef) {
pass(testName);
} else {
fail(testName, `Reference URL mismatch: ${banditRef} !== ${expectedRef}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Handle non-object results gracefully
// -----------------------------------------------------------------------------
async function testHandleNonObjectResults() {
const testName = "SAST: handle non-object results in array";
try {
const output = JSON.stringify({
results: [null, undefined, "string", 123, { valid: "object" }],
});
const parsed = safeJsonParse(output, {
fallback: { results: [] },
label: "test output",
});
// Should parse successfully and include all items
if (parsed && parsed.results && parsed.results.length === 5) {
pass(testName);
} else {
fail(testName, "Failed to preserve all array elements");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: Severity normalization edge cases
// -----------------------------------------------------------------------------
async function testSeverityNormalization_EdgeCases() {
const testName = "SAST: handle severity normalization edge cases";
try {
const unknown = normalizeSeverity("UNKNOWN_SEVERITY");
const empty = normalizeSeverity("");
const whitespace = normalizeSeverity(" ");
// Should handle unknown severities gracefully
const allValid =
typeof unknown === "string" && typeof empty === "string" && typeof whitespace === "string";
if (allValid) {
pass(testName);
} else {
fail(testName, "Severity normalization returned non-string values");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
async function main() {
// Semgrep output parsing tests
await testParseSemgrepOutput_Valid();
await testParseSemgrepOutput_MissingFields();
await testParseSemgrepOutput_Empty();
await testParseSemgrepOutput_Malformed();
// Bandit output parsing tests
await testParseBanditOutput_Valid();
await testParseBanditOutput_MissingFields();
await testParseBanditOutput_Empty();
// Severity normalization tests
await testNormalizeSeverity_Semgrep();
await testNormalizeSeverity_Bandit();
await testSeverityNormalization_EdgeCases();
// Vulnerability structure tests
await testVulnerabilityStructure_Semgrep();
await testVulnerabilityStructure_Bandit();
// Utility tests
await testTimestampFormat();
await testSemgrepMetadata_Variations();
await testReferenceUrlFormats();
await testHandleNonObjectResults();
// Report results
report();
exitWithResults();
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+5 -3
View File
@@ -1,8 +1,8 @@
# Wiki Generation Metadata
- Commit hash: `d5aadfbee15b48ebb4872dfb838e4df88c611d56`
- Branch name: `codex/wiki-tab-ui`
- Generation timestamp (local): `2026-02-26T09:16:02+0200`
- Commit hash: `c3983a100581a9f27eb8cc3b5baa4f585e6c45e4`
- Branch name: `codex/clawsec-scanner-0.0.2-dast-harness`
- Generation timestamp (local): `2026-03-10T19:06:29+0200`
- Generation mode: `update`
- Output language: `English`
- Assets copied into `wiki/assets/`:
@@ -13,6 +13,7 @@
## Notes
- Migrated root documentation pages from `docs/` into dedicated `wiki/` operation pages.
- Updated index and cross-links to use `wiki/` as the documentation source of truth.
- Added a dedicated module page for `clawsec-scanner` and linked it from `wiki/INDEX.md`.
- Future updates should preserve existing headings and append `Update Notes` sections when making deltas.
## Source References
@@ -21,6 +22,7 @@
- AGENTS.md
- wiki/overview.md
- wiki/architecture.md
- wiki/modules/clawsec-scanner.md
- wiki/dependencies.md
- wiki/data-flow.md
- wiki/glossary.md
+4
View File
@@ -29,6 +29,7 @@
## Modules
- [Frontend Web App](modules/frontend-web.md)
- [ClawSec Suite Core](modules/clawsec-suite.md)
- [ClawSec Scanner](modules/clawsec-scanner.md)
- [NanoClaw Integration](modules/nanoclaw-integration.md)
- [Automation and Release Pipelines](modules/automation-release.md)
- [Local Validation and Packaging Tools](modules/local-tooling.md)
@@ -40,6 +41,7 @@
- [Generation Metadata](GENERATION.md)
## Update Notes
- 2026-03-10: Added ClawSec Scanner module documentation and linked it under Modules.
- 2026-02-26: Added Operations pages and updated navigation guidance after migrating root docs into wiki pages.
## Source References
@@ -50,4 +52,6 @@
- scripts/populate-local-feed.sh
- scripts/populate-local-skills.sh
- skills/clawsec-suite/skill.json
- skills/clawsec-scanner/skill.json
- wiki/modules/clawsec-scanner.md
- .github/workflows/ci.yml
+102
View File
@@ -0,0 +1,102 @@
# Module: ClawSec Scanner
## Responsibilities
- Provide multi-layer vulnerability scanning for OpenClaw-oriented skill repositories.
- Orchestrate dependency, SAST, and DAST engines into a single report contract.
- Execute real OpenClaw hook handlers in an isolated DAST harness to validate runtime security behavior.
- Support periodic scan execution through an OpenClaw hook integration.
- Normalize findings into severity buckets for downstream triage and automation.
## Key Files
- `skills/clawsec-scanner/skill.json`: skill metadata, SBOM paths, trigger phrases.
- `skills/clawsec-scanner/scripts/runner.sh`: main orchestrator for dependency/SAST/DAST scans.
- `skills/clawsec-scanner/scripts/scan_dependencies.mjs`: `npm audit` + `pip-audit` parsing.
- `skills/clawsec-scanner/scripts/sast_analyzer.mjs`: Semgrep and Bandit execution/parsing.
- `skills/clawsec-scanner/scripts/dast_runner.mjs`: hook discovery + real harness DAST evaluation.
- `skills/clawsec-scanner/scripts/dast_hook_executor.mjs`: isolated per-hook runtime executor.
- `skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts`: periodic OpenClaw event hook.
- `skills/clawsec-scanner/lib/report.mjs`: unified report generation and text/JSON formatting.
## Public Interfaces
| Interface | Consumer | Behavior |
| --- | --- | --- |
| `runner.sh` CLI | Operators/automation | Runs all enabled scan engines and emits merged report output. |
| `dast_runner.mjs` CLI | Operators/CI/hooks | Discovers hooks and runs isolated runtime DAST checks. |
| OpenClaw scanner hook default export | OpenClaw runtime | Handles `agent:bootstrap` and `command:new` scanner trigger events. |
| `ScanReport` JSON output | Humans and automation | Provides normalized severity summary + finding list. |
## Inputs and Outputs
Inputs/outputs are summarized in the table below.
| Type | Name | Location | Description |
| --- | --- | --- | --- |
| Input | Scan target path | `--target` CLI arg | Root directory where skills/hooks are scanned. |
| Input | Dependency manifests | `package-lock.json`, `requirements.txt`, `pyproject.toml` | Drives dependency vulnerability checks. |
| Input | Hook metadata and handlers | `**/HOOK.md`, `handler.{js,mjs,cjs,ts}` | DAST harness discovers and executes these handlers. |
| Input | Env configuration | `CLAWSEC_*`, `GITHUB_TOKEN` | Controls engine behavior, severity filtering, and output paths. |
| Output | Unified scan report | stdout or `--output` file | JSON/text report with severity summary and finding details. |
| Output | Runtime hook alerts | OpenClaw `event.messages` | New vulnerability alerts pushed into conversations. |
| Output | Scanner state file | `~/.openclaw/clawsec-scanner-state.json` by default | De-duplication memory for reported finding IDs. |
## Configuration
| Variable | Default | Module Effect |
| --- | --- | --- |
| `CLAWSEC_SCANNER_INTERVAL` | `86400` | Minimum interval between periodic hook-triggered scans. |
| `CLAWSEC_SCANNER_MIN_SEVERITY` | `medium` | Threshold for findings pushed to conversation alerts. |
| `CLAWSEC_SCANNER_FORMAT` | `text` | Hook alert serialization format (`text` or `json`). |
| `CLAWSEC_SKIP_DEPENDENCY_SCAN` | `0` | Disables dependency scanner when set to `1`. |
| `CLAWSEC_SKIP_SAST` | `0` | Disables Semgrep/Bandit scanner when set to `1`. |
| `CLAWSEC_SKIP_DAST` | `0` | Disables runtime hook DAST checks when set to `1`. |
| `CLAWSEC_SKIP_CVE_LOOKUP` | `0` | Disables CVE enrichment stage when set to `1`. |
| `CLAWSEC_DAST_HARNESS` | unset | Internal guard to avoid recursive scans during harness execution. |
| `CLAWSEC_DAST_DISABLE_TYPESCRIPT` | unset | Test/debug switch forcing TypeScript harness coverage fallback mode. |
## DAST Harness Behavior
- Hook discovery walks the target tree for `HOOK.md` and resolves adjacent handler files.
- Each declared event key is executed in a separate Node subprocess via `dast_hook_executor.mjs`.
- Findings are generated from real runtime behavior:
- Baseline execution crash or timeout.
- Malicious-input crash or timeout.
- Output amplification beyond message/character thresholds.
- Core event identity mutation (`type`, `action`, `sessionKey`).
- Harness capability gaps (for example missing TypeScript compiler for `.ts` handlers) are reported as `info` coverage findings, not high-severity vulnerabilities.
## Example Snippets
```bash
# run scanner end-to-end
bash skills/clawsec-scanner/scripts/runner.sh --target ./skills --format json
```
```bash
# run DAST harness directly
node skills/clawsec-scanner/scripts/dast_runner.mjs --target ./skills --format text --timeout 30000
```
## Tests
| Test File | Focus |
| --- | --- |
| `skills/clawsec-scanner/test/dast_harness.test.mjs` | Real hook execution path, malicious crash detection, TypeScript coverage fallback semantics. |
| `skills/clawsec-scanner/test/reviewer_regressions.test.mjs` | Runner behavior around non-zero DAST exit and merged reporting. |
| `skills/clawsec-scanner/test/dependency_scanner.test.mjs` | Dependency scanner utility/report contracts. |
| `skills/clawsec-scanner/test/sast_engine.test.mjs` | SAST parser/normalization behavior. |
| `skills/clawsec-scanner/test/cve_integration.test.mjs` | OSV/NVD/GitHub enrichment integration checks. |
## Update Notes
- 2026-03-10: Added module page for `clawsec-scanner` and documented the `0.0.2` real OpenClaw DAST harness execution model.
## Source References
- skills/clawsec-scanner/skill.json
- skills/clawsec-scanner/SKILL.md
- skills/clawsec-scanner/CHANGELOG.md
- skills/clawsec-scanner/scripts/runner.sh
- skills/clawsec-scanner/scripts/scan_dependencies.mjs
- skills/clawsec-scanner/scripts/sast_analyzer.mjs
- skills/clawsec-scanner/scripts/dast_runner.mjs
- skills/clawsec-scanner/scripts/dast_hook_executor.mjs
- skills/clawsec-scanner/scripts/setup_scanner_hook.mjs
- skills/clawsec-scanner/hooks/clawsec-scanner-hook/HOOK.md
- skills/clawsec-scanner/hooks/clawsec-scanner-hook/handler.ts
- skills/clawsec-scanner/lib/report.mjs
- skills/clawsec-scanner/lib/utils.mjs
- skills/clawsec-scanner/test/dast_harness.test.mjs
- skills/clawsec-scanner/test/reviewer_regressions.test.mjs