mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-19 00:11:20 +03:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0f0f1db97 | |||
| 687822b6cb | |||
| e715c8a625 | |||
| bd54393ed4 | |||
| 0fcc6e6b6d | |||
| 8d292457fb | |||
| 1cced651a0 | |||
| 83ce1d0bf5 | |||
| f9a7565d6f | |||
| 81c2e60513 | |||
| 19b53609c1 | |||
| 79c303fa3f | |||
| e0eae65586 | |||
| 56a36b7e52 | |||
| 8ad38dfdc6 | |||
| 3c336021d7 | |||
| 073e771b73 | |||
| 382db82483 | |||
| c9a66d5c99 | |||
| e4ca378603 | |||
| 5c5c7f539a | |||
| 7c0aa37a05 | |||
| 86342d2789 | |||
| 95c856ad8a | |||
| fefecaa60a | |||
| 8132c23f41 | |||
| 433a9596a6 | |||
| c17931d38d | |||
| 516e8f0428 | |||
| cbc484faf3 | |||
| 448aed3261 | |||
| 037bd125b9 | |||
| 5ef122dd91 | |||
| 938eb929f3 | |||
| 55fb234fc0 | |||
| ea44aea49e | |||
| 2e64201254 | |||
| 371d792e97 | |||
| 0602c0fbe5 | |||
| 8908319dd0 | |||
| 6f2fe918a2 | |||
| 7cdb4ab7e2 | |||
| 73dd63f714 | |||
| db0339084f | |||
| af0a515166 | |||
| 3142707dbd | |||
| c6409d2641 | |||
| e06c3952a3 | |||
| c61e4e5dbc | |||
| bd8931a094 | |||
| be5140aaae | |||
| 047b3ffa06 | |||
| 143dd311c6 | |||
| f43f792a88 | |||
| bfd230a178 | |||
| d5cf5c0b9c | |||
| 74a6d23a20 | |||
| 5e2f623ead | |||
| b05265fba1 | |||
| 176aa1f06a | |||
| 63de5ce08d |
@@ -0,0 +1,24 @@
|
||||
* text=auto
|
||||
|
||||
# Keep executable/script sources LF across platforms.
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
*.zsh text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.js text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.py text eol=lf
|
||||
|
||||
# Keep config/docs deterministic in CI and local tooling.
|
||||
*.md text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.pem text eol=lf
|
||||
|
||||
# Binary assets.
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.ttf binary
|
||||
@@ -1,2 +1,2 @@
|
||||
ruff==0.6.9
|
||||
bandit==1.7.9
|
||||
ruff==0.15.2
|
||||
bandit==1.9.4
|
||||
|
||||
@@ -3,18 +3,24 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
lint-typescript:
|
||||
name: Lint TypeScript/React
|
||||
runs-on: ubuntu-latest
|
||||
name: Lint TypeScript/React (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- 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'
|
||||
@@ -24,6 +30,7 @@ jobs:
|
||||
- name: TypeScript Check
|
||||
run: npx tsc --noEmit
|
||||
- name: Build Check
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: npm run build
|
||||
|
||||
lint-python:
|
||||
@@ -56,7 +63,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Trivy FS Scan
|
||||
uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # 0.34.0
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
@@ -64,7 +71,7 @@ jobs:
|
||||
exit-code: '1'
|
||||
ignore-unfixed: true
|
||||
- name: Trivy Config Scan
|
||||
uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # 0.34.0
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
with:
|
||||
scan-type: 'config'
|
||||
scan-ref: '.'
|
||||
@@ -76,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'
|
||||
@@ -91,10 +98,39 @@ 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'
|
||||
- run: npm ci
|
||||
- name: Feed Verification Tests
|
||||
run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||
- name: Guarded Install Tests
|
||||
run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
- name: Advisory Suppression Tests
|
||||
run: node skills/clawsec-suite/test/advisory_suppression.test.mjs
|
||||
- name: Path Resolution Tests
|
||||
run: node skills/clawsec-suite/test/path_resolution.test.mjs
|
||||
- name: Fuzz Property Tests
|
||||
run: node skills/clawsec-suite/test/fuzz_properties.test.mjs
|
||||
- name: Semver/Scope/Suppression Fuzz Tests
|
||||
run: node skills/clawsec-suite/test/fuzz_semver_scope_suppression.test.mjs
|
||||
- name: Advisory Application Scope Tests
|
||||
run: node skills/clawsec-suite/test/advisory_application_scope.test.mjs
|
||||
|
||||
openclaw-audit-watchdog-tests:
|
||||
name: OpenClaw Audit Watchdog Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: Suppression Config Tests
|
||||
run: node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
|
||||
- name: Suppression Config Fuzz Tests
|
||||
run: node skills/openclaw-audit-watchdog/test/suppression_config_fuzz.test.mjs
|
||||
- name: Render Report Suppression Tests
|
||||
run: node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
|
||||
@@ -28,7 +27,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
@@ -38,4 +37,4 @@ jobs:
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
|
||||
@@ -20,10 +20,6 @@ jobs:
|
||||
process-advisory:
|
||||
if: github.event.label.name == 'advisory-approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -117,6 +113,32 @@ jobs:
|
||||
fi
|
||||
echo "Affected: $AFFECTED"
|
||||
|
||||
# Build platforms array
|
||||
OPENCLAW_SELECTED="false"
|
||||
if echo "$ISSUE_BODY" | grep -qi '^[[:space:]]*-[[:space:]]*\[[xX]\][[:space:]]*OpenClaw'; then
|
||||
OPENCLAW_SELECTED="true"
|
||||
fi
|
||||
|
||||
OTHER_PLATFORM_RAW=$(echo "$ISSUE_BODY" | sed -n 's/^[[:space:]]*-[[:space:]]*\[[xX]\][[:space:]]*Other:[[:space:]]*\(.*\)$/\1/p' | head -1 | xargs)
|
||||
OTHER_PLATFORM=""
|
||||
if [ -n "$OTHER_PLATFORM_RAW" ]; then
|
||||
OTHER_PLATFORM=$(echo "$OTHER_PLATFORM_RAW" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')
|
||||
if echo "$OTHER_PLATFORM" | grep -q 'nanoclaw'; then
|
||||
OTHER_PLATFORM="nanoclaw"
|
||||
fi
|
||||
fi
|
||||
|
||||
PLATFORMS=$(jq -n --arg open "$OPENCLAW_SELECTED" --arg other "$OTHER_PLATFORM" '
|
||||
[
|
||||
(if $open == "true" then "openclaw" else empty end),
|
||||
(if ($other | length) > 0 then $other else empty end)
|
||||
] | unique
|
||||
')
|
||||
if [ "$PLATFORMS" = "[]" ]; then
|
||||
PLATFORMS='["openclaw","nanoclaw"]'
|
||||
fi
|
||||
echo "Platforms: $PLATFORMS"
|
||||
|
||||
# Parse recommended action
|
||||
ACTION=$(echo "$ISSUE_BODY" | sed -n '/^## Recommended Action/,/^---/p' | grep -v '^## Recommended Action' | grep -v '^---' | grep -v '^<!--' | sed '/^\s*$/d' | tr '\n' ' ' | xargs)
|
||||
if [ -z "$ACTION" ]; then
|
||||
@@ -142,6 +164,7 @@ jobs:
|
||||
--arg title "$TITLE" \
|
||||
--arg description "$DESCRIPTION" \
|
||||
--argjson affected "$AFFECTED" \
|
||||
--argjson platforms "$PLATFORMS" \
|
||||
--arg action "$ACTION" \
|
||||
--arg published "$PUBLISHED" \
|
||||
--arg source "Community Report" \
|
||||
@@ -155,6 +178,7 @@ jobs:
|
||||
title: $title,
|
||||
description: $description,
|
||||
affected: $affected,
|
||||
platforms: $platforms,
|
||||
action: $action,
|
||||
published: $published,
|
||||
references: [],
|
||||
@@ -169,6 +193,27 @@ jobs:
|
||||
echo "Created advisory JSON:"
|
||||
cat tmp_advisory.json
|
||||
|
||||
- name: Set up Python for exploitability analysis
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Analyze exploitability for community advisory
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Analyzing exploitability for community advisory ==="
|
||||
|
||||
scripts/ci/enrich_exploitability.sh \
|
||||
--mode single \
|
||||
--input tmp_advisory.json \
|
||||
--output tmp_advisory.json
|
||||
|
||||
echo "=== Exploitability analysis complete ==="
|
||||
echo "Exploitability score: $(jq -r '.exploitability_score // "unknown"' tmp_advisory.json)"
|
||||
|
||||
- name: Update feed
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
run: |
|
||||
@@ -216,12 +261,21 @@ jobs:
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
|
||||
- name: Require automation token for write operations
|
||||
env:
|
||||
AUTOMATION_TOKEN: ${{ secrets.POLL_NVD_CVES_PAT }}
|
||||
run: |
|
||||
if [ -z "$AUTOMATION_TOKEN" ]; then
|
||||
echo "::error::Set POLL_NVD_CVES_PAT with repo write permissions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ secrets.POLL_NVD_CVES_PAT }}
|
||||
branch: automated/community-advisory-${{ github.event.issue.number }}
|
||||
delete-branch: true
|
||||
title: "chore: add community advisory ${{ steps.parse.outputs.advisory_id }}"
|
||||
@@ -250,6 +304,7 @@ jobs:
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.POLL_NVD_CVES_PAT }}
|
||||
script: |
|
||||
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
||||
const pullRequestUrl = '${{ steps.create-pr.outputs.pull-request-url }}';
|
||||
@@ -275,6 +330,7 @@ jobs:
|
||||
if: steps.parse.outputs.already_exists == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.POLL_NVD_CVES_PAT }}
|
||||
script: |
|
||||
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_run:
|
||||
workflows: ["CI", "Skill Release"]
|
||||
workflows: ["Skill Release"]
|
||||
types: [completed]
|
||||
# Note: No branch restriction - must trigger on both main branch CI runs AND tag-based Skill Releases
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -19,8 +20,20 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run if workflow_dispatch OR the triggering workflow succeeded
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||
# Production build only: manual dispatch, push to main, or trusted release workflows.
|
||||
# PR validation runs in .github/workflows/pages-verify.yml.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'push' &&
|
||||
github.ref_name == 'main'
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.name == 'Skill Release' &&
|
||||
github.event.workflow_run.event != 'pull_request'
|
||||
)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -305,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'
|
||||
@@ -401,7 +414,19 @@ jobs:
|
||||
path: ./dist
|
||||
|
||||
deploy:
|
||||
# Deploy after build succeeds (CI or Skill Release must pass first, or manual dispatch)
|
||||
# Deploy after a production build succeeds.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'push' &&
|
||||
github.ref_name == 'main'
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.name == 'Skill Release' &&
|
||||
github.event.workflow_run.event != 'pull_request'
|
||||
)
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Pages Verify
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pages-verify-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify-pages-build:
|
||||
name: Verify Pages Build (No Publish)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify signing key consistency (repo + docs)
|
||||
run: ./scripts/ci/verify_signing_key_consistency.sh
|
||||
|
||||
- name: Prepare advisory artifacts for pre-deploy checks
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p public/advisories
|
||||
cp advisories/feed.json public/advisories/feed.json
|
||||
|
||||
- name: Generate advisory checksums manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
FEED_FILE="public/advisories/feed.json"
|
||||
FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}')
|
||||
FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE")
|
||||
|
||||
jq -n \
|
||||
--arg schema_version "1" \
|
||||
--arg algorithm "sha256" \
|
||||
--arg version "1.1.0" \
|
||||
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg repo "${{ github.repository }}" \
|
||||
--arg sha "$FEED_SHA" \
|
||||
--argjson size "$FEED_SIZE" \
|
||||
'{
|
||||
schema_version: $schema_version,
|
||||
algorithm: $algorithm,
|
||||
version: $version,
|
||||
generated_at: $generated,
|
||||
repository: $repo,
|
||||
files: {
|
||||
"advisories/feed.json": {
|
||||
sha256: $sha,
|
||||
size: $size,
|
||||
path: "advisories/feed.json",
|
||||
url: "https://clawsec.prompt.security/advisories/feed.json"
|
||||
}
|
||||
}
|
||||
}' > public/checksums.json
|
||||
|
||||
- name: Generate ephemeral signing key for PR verification
|
||||
id: test_key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY_FILE=$(mktemp)
|
||||
openssl genpkey -algorithm Ed25519 -out "$KEY_FILE"
|
||||
{
|
||||
echo "private_key<<EOF"
|
||||
cat "$KEY_FILE"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
rm -f "$KEY_FILE"
|
||||
|
||||
- name: Sign advisory feed and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ steps.test_key.outputs.private_key }}
|
||||
input_file: public/advisories/feed.json
|
||||
signature_file: public/advisories/feed.json.sig
|
||||
public_key_output: public/signing-public.pem
|
||||
|
||||
- name: Sign checksums and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ steps.test_key.outputs.private_key }}
|
||||
input_file: public/checksums.json
|
||||
signature_file: public/checksums.sig
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build site
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Sanity-check generated artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f dist/index.html
|
||||
test -f public/advisories/feed.json.sig
|
||||
test -f public/checksums.sig
|
||||
test -f public/signing-public.pem
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_full_scan:
|
||||
description: 'Ignore last poll date and scan all CVEs'
|
||||
description: 'Ignore feed state and rebuild CVE advisories from full NVD history'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
@@ -23,13 +23,14 @@ env:
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
KEYWORDS: "OpenClaw clawdbot Moltbot"
|
||||
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
|
||||
KEYWORDS: "OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys"
|
||||
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw github.com/qwibitai/NanoClaw"
|
||||
|
||||
jobs:
|
||||
poll-and-update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -85,6 +86,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p tmp
|
||||
FORCE_FULL_SCAN="${{ inputs.force_full_scan }}"
|
||||
|
||||
START_DATE="${{ steps.dates.outputs.start_date }}"
|
||||
END_DATE="${{ steps.dates.outputs.end_date }}"
|
||||
@@ -100,35 +102,93 @@ jobs:
|
||||
# Fetch for each keyword
|
||||
for KEYWORD in $KEYWORDS; do
|
||||
echo "Fetching keyword: $KEYWORD"
|
||||
|
||||
URL="https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=${KEYWORD}&lastModStartDate=${START_ENC}&lastModEndDate=${END_ENC}"
|
||||
echo "URL: $URL"
|
||||
|
||||
# Fetch with retry logic
|
||||
|
||||
keyword_ok=false
|
||||
last_http_code=""
|
||||
for i in 1 2 3; do
|
||||
HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL" || true)
|
||||
if [ -z "$HTTP_CODE" ]; then
|
||||
HTTP_CODE="000"
|
||||
fi
|
||||
last_http_code="$HTTP_CODE"
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
if jq -e . "tmp/nvd_${KEYWORD}.json" >/dev/null 2>&1; then
|
||||
echo "Success for $KEYWORD"
|
||||
|
||||
if [ "$FORCE_FULL_SCAN" = "true" ]; then
|
||||
echo "Full scan mode enabled: paginating complete NVD history for keyword '$KEYWORD'"
|
||||
echo '{"vulnerabilities":[]}' > "tmp/nvd_${KEYWORD}.json"
|
||||
START_INDEX=0
|
||||
RESULTS_PER_PAGE=2000
|
||||
|
||||
while true; do
|
||||
URL="https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=${KEYWORD}&startIndex=${START_INDEX}&resultsPerPage=${RESULTS_PER_PAGE}"
|
||||
PAGE_FILE="tmp/nvd_${KEYWORD}_${START_INDEX}.json"
|
||||
echo "URL: $URL"
|
||||
|
||||
page_ok=false
|
||||
for i in 1 2 3; do
|
||||
HTTP_CODE=$(curl -sS -w "%{http_code}" -o "$PAGE_FILE" "$URL" || true)
|
||||
if [ -z "$HTTP_CODE" ]; then
|
||||
HTTP_CODE="000"
|
||||
fi
|
||||
last_http_code="$HTTP_CODE"
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
if jq -e . "$PAGE_FILE" >/dev/null 2>&1; then
|
||||
page_ok=true
|
||||
break
|
||||
fi
|
||||
echo "Invalid JSON for $KEYWORD page $START_INDEX, retry $i..."
|
||||
sleep 5
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
|
||||
echo "Rate limited, waiting 30s before retry $i..."
|
||||
sleep 30
|
||||
else
|
||||
echo "HTTP $HTTP_CODE for $KEYWORD page $START_INDEX, retry $i..."
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$page_ok" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
jq -s '.[0].vulnerabilities += .[1].vulnerabilities | .[0]' \
|
||||
"tmp/nvd_${KEYWORD}.json" "$PAGE_FILE" > "tmp/nvd_${KEYWORD}_merged.json"
|
||||
mv "tmp/nvd_${KEYWORD}_merged.json" "tmp/nvd_${KEYWORD}.json"
|
||||
|
||||
PAGE_COUNT=$(jq '.vulnerabilities | length' "$PAGE_FILE")
|
||||
TOTAL_RESULTS=$(jq '.totalResults // 0' "$PAGE_FILE")
|
||||
echo "Fetched $PAGE_COUNT results at startIndex=$START_INDEX (totalResults=$TOTAL_RESULTS)"
|
||||
|
||||
START_INDEX=$((START_INDEX + RESULTS_PER_PAGE))
|
||||
if [ "$START_INDEX" -ge "$TOTAL_RESULTS" ] || [ "$PAGE_COUNT" -eq 0 ]; then
|
||||
keyword_ok=true
|
||||
break
|
||||
fi
|
||||
echo "Invalid JSON for $KEYWORD, retry $i..."
|
||||
sleep 5
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
|
||||
echo "Rate limited, waiting 30s before retry $i..."
|
||||
sleep 30
|
||||
else
|
||||
echo "HTTP $HTTP_CODE for $KEYWORD, retry $i..."
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
|
||||
# NVD recommends 6 second delay between requests
|
||||
sleep 6
|
||||
done
|
||||
else
|
||||
URL="https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=${KEYWORD}&lastModStartDate=${START_ENC}&lastModEndDate=${END_ENC}"
|
||||
echo "URL: $URL"
|
||||
|
||||
# Fetch with retry logic
|
||||
for i in 1 2 3; do
|
||||
HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL" || true)
|
||||
if [ -z "$HTTP_CODE" ]; then
|
||||
HTTP_CODE="000"
|
||||
fi
|
||||
last_http_code="$HTTP_CODE"
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
if jq -e . "tmp/nvd_${KEYWORD}.json" >/dev/null 2>&1; then
|
||||
echo "Success for $KEYWORD"
|
||||
keyword_ok=true
|
||||
break
|
||||
fi
|
||||
echo "Invalid JSON for $KEYWORD, retry $i..."
|
||||
sleep 5
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
|
||||
echo "Rate limited, waiting 30s before retry $i..."
|
||||
sleep 30
|
||||
else
|
||||
echo "HTTP $HTTP_CODE for $KEYWORD, retry $i..."
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$keyword_ok" != "true" ]; then
|
||||
echo "::error::Failed to fetch valid NVD response for keyword '$KEYWORD' (last HTTP code: ${last_http_code:-unknown})."
|
||||
@@ -175,7 +235,7 @@ jobs:
|
||||
echo "Total unique CVEs from NVD: $TOTAL"
|
||||
|
||||
# Post-filter: keep only CVEs where description contains keywords OR references contain github pattern
|
||||
KEYWORDS_PATTERN="OpenClaw|clawdbot|Moltbot|openclaw"
|
||||
KEYWORDS_PATTERN="OpenClaw|clawdbot|Moltbot|openclaw|NanoClaw|nanoclaw|WhatsApp-bot|baileys"
|
||||
GITHUB_PATTERN="${GITHUB_REF_PATTERN}"
|
||||
|
||||
jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_PATTERN" '
|
||||
@@ -211,6 +271,14 @@ jobs:
|
||||
- name: Check for updates to existing advisories
|
||||
id: updates
|
||||
run: |
|
||||
if [ "${{ inputs.force_full_scan }}" = "true" ]; then
|
||||
echo "Full scan mode enabled: skipping delta update detection."
|
||||
echo '[]' > tmp/updated_advisories.json
|
||||
echo "Advisories to update: 0"
|
||||
echo "update_count=0" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Compare existing CVE advisories against NVD data for changes
|
||||
# Only check advisories that start with "CVE-" (NVD-sourced)
|
||||
|
||||
@@ -297,6 +365,51 @@ jobs:
|
||||
end
|
||||
);
|
||||
|
||||
def cpe_criteria:
|
||||
(
|
||||
[.cve.configurations[]? | .. | objects | .criteria? | strings | select(startswith("cpe:2.3:"))]
|
||||
| unique
|
||||
);
|
||||
|
||||
def context_blob:
|
||||
(
|
||||
[
|
||||
(.cve.descriptions[]? | select(.lang == "en") | .value),
|
||||
(.cve.references[]?.url // empty)
|
||||
]
|
||||
| map(strings | ascii_downcase)
|
||||
| join(" ")
|
||||
);
|
||||
|
||||
def inferred_targets:
|
||||
(
|
||||
context_blob as $blob
|
||||
| (
|
||||
(if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end)
|
||||
+ (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end)
|
||||
)
|
||||
);
|
||||
|
||||
def normalized_affected:
|
||||
(
|
||||
(cpe_criteria + inferred_targets)
|
||||
| unique
|
||||
| .[0:5]
|
||||
| if length == 0 then ["openclaw@*", "nanoclaw@*"] else . end
|
||||
);
|
||||
|
||||
def normalized_platforms:
|
||||
(
|
||||
inferred_targets as $targets
|
||||
| ($targets | map(select(startswith("openclaw@"))) | length > 0) as $has_openclaw
|
||||
| ($targets | map(select(startswith("nanoclaw@"))) | length > 0) as $has_nanoclaw
|
||||
| if $has_openclaw and $has_nanoclaw then ["openclaw", "nanoclaw"]
|
||||
elif $has_openclaw then ["openclaw"]
|
||||
elif $has_nanoclaw then ["nanoclaw"]
|
||||
else ["openclaw", "nanoclaw"]
|
||||
end
|
||||
);
|
||||
|
||||
[.[] | {
|
||||
id: .cve.id,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
@@ -305,7 +418,11 @@ jobs:
|
||||
cvss_score: get_cvss_score,
|
||||
description: (.cve.descriptions[] | select(.lang == "en") | .value),
|
||||
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
|
||||
references: [.cve.references[]?.url // empty] | unique | .[0:3]
|
||||
affected: normalized_affected,
|
||||
platforms: normalized_platforms,
|
||||
references: [.cve.references[]?.url // empty] | unique | .[0:3],
|
||||
exploitability_score: null,
|
||||
exploitability_rationale: null
|
||||
}]
|
||||
' tmp/filtered_cves.json > tmp/nvd_current_state.json
|
||||
|
||||
@@ -325,6 +442,8 @@ jobs:
|
||||
($existing_entry.type != $nvd_entry.type) or
|
||||
($existing_entry.nvd_category_id != $nvd_entry.nvd_category_id) or
|
||||
($existing_entry.cvss_score != $nvd_entry.cvss_score) or
|
||||
($existing_entry.affected != $nvd_entry.affected) or
|
||||
($existing_entry.platforms != $nvd_entry.platforms) or
|
||||
($existing_entry.description != $nvd_entry.description) then
|
||||
{
|
||||
id: $nvd_entry.id,
|
||||
@@ -334,6 +453,8 @@ jobs:
|
||||
+ (if $existing_entry.type != $nvd_entry.type then ["type: \($existing_entry.type // "null") → \($nvd_entry.type // "null")"] else [] end)
|
||||
+ (if $existing_entry.nvd_category_id != $nvd_entry.nvd_category_id then ["nvd_category_id: \($existing_entry.nvd_category_id // "null") → \($nvd_entry.nvd_category_id // "null")"] else [] end)
|
||||
+ (if $existing_entry.cvss_score != $nvd_entry.cvss_score then ["cvss_score: \($existing_entry.cvss_score // "null") → \($nvd_entry.cvss_score // "null")"] else [] end)
|
||||
+ (if $existing_entry.affected != $nvd_entry.affected then ["affected targets updated"] else [] end)
|
||||
+ (if $existing_entry.platforms != $nvd_entry.platforms then ["platforms updated"] else [] end)
|
||||
+ (if $existing_entry.description != $nvd_entry.description then ["description updated"] else [] end)
|
||||
),
|
||||
updated_fields: {
|
||||
@@ -341,6 +462,8 @@ jobs:
|
||||
type: $nvd_entry.type,
|
||||
nvd_category_id: $nvd_entry.nvd_category_id,
|
||||
cvss_score: $nvd_entry.cvss_score,
|
||||
affected: $nvd_entry.affected,
|
||||
platforms: $nvd_entry.platforms,
|
||||
description: $nvd_entry.description,
|
||||
title: $nvd_entry.title,
|
||||
references: $nvd_entry.references
|
||||
@@ -368,7 +491,12 @@ jobs:
|
||||
id: transform
|
||||
run: |
|
||||
# Read existing IDs into a jq-friendly format
|
||||
EXISTING_IDS=$(cat tmp/existing_ids.txt | jq -R -s 'split("\n") | map(select(length > 0))')
|
||||
if [ "${{ inputs.force_full_scan }}" = "true" ]; then
|
||||
echo "Full scan mode enabled: rebuilding CVE advisories from scratch."
|
||||
EXISTING_IDS='[]'
|
||||
else
|
||||
EXISTING_IDS=$(cat tmp/existing_ids.txt | jq -R -s 'split("\n") | map(select(length > 0))')
|
||||
fi
|
||||
|
||||
# Transform NVD CVEs to our advisory format
|
||||
jq --argjson existing "$EXISTING_IDS" '
|
||||
@@ -453,8 +581,53 @@ jobs:
|
||||
else (cwe_name_map($id) // ("unknown_cwe_" + $id))
|
||||
end
|
||||
);
|
||||
|
||||
def cpe_criteria:
|
||||
(
|
||||
[.cve.configurations[]? | .. | objects | .criteria? | strings | select(startswith("cpe:2.3:"))]
|
||||
| unique
|
||||
);
|
||||
|
||||
def context_blob:
|
||||
(
|
||||
[
|
||||
(.cve.descriptions[]? | select(.lang == "en") | .value),
|
||||
(.cve.references[]?.url // empty)
|
||||
]
|
||||
| map(strings | ascii_downcase)
|
||||
| join(" ")
|
||||
);
|
||||
|
||||
def inferred_targets:
|
||||
(
|
||||
context_blob as $blob
|
||||
| (
|
||||
(if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end)
|
||||
+ (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end)
|
||||
)
|
||||
);
|
||||
|
||||
def normalized_affected:
|
||||
(
|
||||
(cpe_criteria + inferred_targets)
|
||||
| unique
|
||||
| .[0:5]
|
||||
| if length == 0 then ["openclaw@*", "nanoclaw@*"] else . end
|
||||
);
|
||||
|
||||
def normalized_platforms:
|
||||
(
|
||||
inferred_targets as $targets
|
||||
| ($targets | map(select(startswith("openclaw@"))) | length > 0) as $has_openclaw
|
||||
| ($targets | map(select(startswith("nanoclaw@"))) | length > 0) as $has_nanoclaw
|
||||
| if $has_openclaw and $has_nanoclaw then ["openclaw", "nanoclaw"]
|
||||
elif $has_openclaw then ["openclaw"]
|
||||
elif $has_nanoclaw then ["nanoclaw"]
|
||||
else ["openclaw", "nanoclaw"]
|
||||
end
|
||||
);
|
||||
|
||||
[.[] |
|
||||
[.[] |
|
||||
select(.cve.id as $id | $existing | index($id) | not) |
|
||||
{
|
||||
id: .cve.id,
|
||||
@@ -463,12 +636,15 @@ jobs:
|
||||
nvd_category_id: nvd_category_raw,
|
||||
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
|
||||
description: (.cve.descriptions[] | select(.lang == "en") | .value),
|
||||
affected: [.cve.configurations[]?.nodes[]?.cpeMatch[]?.criteria // empty] | unique | .[0:5],
|
||||
affected: normalized_affected,
|
||||
platforms: normalized_platforms,
|
||||
action: "Review and update affected components. See NVD for remediation details.",
|
||||
published: .cve.published,
|
||||
references: [.cve.references[]?.url // empty] | unique | .[0:3],
|
||||
cvss_score: get_cvss_score,
|
||||
nvd_url: ("https://nvd.nist.gov/vuln/detail/" + .cve.id)
|
||||
nvd_url: ("https://nvd.nist.gov/vuln/detail/" + .cve.id),
|
||||
exploitability_score: null,
|
||||
exploitability_rationale: null
|
||||
}
|
||||
]
|
||||
' tmp/filtered_cves.json > tmp/new_advisories.json
|
||||
@@ -482,12 +658,63 @@ jobs:
|
||||
jq '.[].id' tmp/new_advisories.json
|
||||
fi
|
||||
|
||||
- name: Set up Python for exploitability analysis
|
||||
if: steps.transform.outputs.new_count != '0'
|
||||
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Analyze exploitability for new advisories
|
||||
if: steps.transform.outputs.new_count != '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Analyzing exploitability for new advisories ==="
|
||||
|
||||
# Extract CVSS vectors from filtered CVEs to merge with advisories
|
||||
jq '
|
||||
[.[] | {
|
||||
id: .cve.id,
|
||||
cvss_vector: (
|
||||
.cve.metrics.cvssMetricV31[0]?.cvssData.vectorString //
|
||||
.cve.metrics.cvssMetricV30[0]?.cvssData.vectorString //
|
||||
.cve.metrics.cvssMetricV2[0]?.vectorString //
|
||||
""
|
||||
)
|
||||
}] | map({(.id): .cvss_vector}) | add
|
||||
' tmp/filtered_cves.json > tmp/cvss_vectors.json
|
||||
|
||||
scripts/ci/enrich_exploitability.sh \
|
||||
--mode batch \
|
||||
--input tmp/new_advisories.json \
|
||||
--output tmp/new_advisories.json \
|
||||
--cvss-vectors tmp/cvss_vectors.json
|
||||
|
||||
echo "=== Exploitability analysis complete ==="
|
||||
|
||||
# Show summary of exploitability scores
|
||||
echo "Exploitability score distribution:"
|
||||
jq -r '.[] | "\(.id): \(.exploitability_score // "unknown")"' tmp/new_advisories.json | \
|
||||
awk -F': ' '{scores[$2]++} END {for (s in scores) print " " s ": " scores[s]}'
|
||||
|
||||
- name: Update feed.json
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
run: |
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
FORCE_FULL_SCAN="${{ inputs.force_full_scan }}"
|
||||
|
||||
if [ -f "$FEED_PATH" ]; then
|
||||
if [ -f "$FEED_PATH" ] && [ "$FORCE_FULL_SCAN" = "true" ]; then
|
||||
# Full scan mode: replace all CVE advisories with rebuilt set and keep non-CVE entries.
|
||||
jq --argjson rebuilt "$(cat tmp/new_advisories.json)" --arg now "$NOW" '
|
||||
.updated = $now |
|
||||
.advisories = (
|
||||
((.advisories // []) | map(select((.id // "") | startswith("CVE-") | not)))
|
||||
+ $rebuilt
|
||||
| sort_by(.published)
|
||||
| reverse
|
||||
)
|
||||
' "$FEED_PATH" > tmp/updated_feed.json
|
||||
elif [ -f "$FEED_PATH" ]; then
|
||||
# Step 1: Apply updates to existing advisories
|
||||
jq --slurpfile updates tmp/updated_advisories.json '
|
||||
.advisories = [
|
||||
@@ -571,6 +798,7 @@ jobs:
|
||||
## Summary
|
||||
Automated update from NVD CVE feed.
|
||||
|
||||
- **Mode:** ${{ inputs.force_full_scan == true && 'full-rebuild (ignore feed state)' || 'delta (incremental)' }}
|
||||
- **New advisories:** ${{ steps.transform.outputs.new_count }}
|
||||
- **Updated advisories:** ${{ steps.updates.outputs.update_count }}
|
||||
- **Poll window:** ${{ steps.dates.outputs.start_date }} → ${{ steps.dates.outputs.end_date }}
|
||||
@@ -590,12 +818,57 @@ jobs:
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
${{ env.SKILL_FEED_SIG_PATH }}
|
||||
|
||||
- name: Run CodeQL on generated PR branch
|
||||
if: steps.create-pr.outputs.pull-request-number != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="${{ steps.create-pr.outputs.pull-request-branch }}"
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::Missing pull-request-branch output from create-pull-request"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Dispatching CodeQL for branch: $BRANCH"
|
||||
gh workflow run codeql.yml --ref "$BRANCH"
|
||||
|
||||
RUN_ID=""
|
||||
for _ in $(seq 1 30); do
|
||||
RUN_ID=$(gh run list \
|
||||
--workflow "CodeQL" \
|
||||
--branch "$BRANCH" \
|
||||
--event workflow_dispatch \
|
||||
--json databaseId,createdAt \
|
||||
--jq 'sort_by(.createdAt) | last | .databaseId // empty')
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "::error::Unable to locate dispatched CodeQL run for branch $BRANCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for CodeQL run id: $RUN_ID"
|
||||
gh run watch "$RUN_ID" --exit-status
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
if [ "${{ inputs.force_full_scan }}" = "true" ]; then
|
||||
MODE="full-rebuild (ignore feed state)"
|
||||
else
|
||||
MODE="delta (incremental)"
|
||||
fi
|
||||
|
||||
echo "## NVD CVE Poll Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Mode | $MODE |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Poll Window | ${{ steps.dates.outputs.start_date }} → ${{ steps.dates.outputs.end_date }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Keywords | $KEYWORDS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| CVEs Found (filtered) | ${{ steps.process.outputs.filtered_count }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -11,8 +11,6 @@ on:
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '19 23 * * 0'
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
@@ -34,12 +32,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
@@ -64,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
|
||||
@@ -73,6 +71,6 @@ jobs:
|
||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -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
|
||||
@@ -293,6 +296,20 @@ jobs:
|
||||
' "$md_file"
|
||||
}
|
||||
|
||||
get_md_version_from_git() {
|
||||
local sha="$1"
|
||||
local path="$2"
|
||||
local tmp_file
|
||||
tmp_file="$(mktemp)"
|
||||
|
||||
if git cat-file -e "${sha}:${path}" 2>/dev/null; then
|
||||
git show "${sha}:${path}" > "$tmp_file"
|
||||
get_md_version "$tmp_file"
|
||||
fi
|
||||
|
||||
rm -f "$tmp_file"
|
||||
}
|
||||
|
||||
touched_skills_file="$(mktemp)"
|
||||
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- 'skills/*/skill.json' 'skills/*/SKILL.md' \
|
||||
| awk -F/ 'NF >= 3 {print $1 "/" $2}' \
|
||||
@@ -322,6 +339,29 @@ jobs:
|
||||
head_md_version="$(get_md_version "${md_path}")"
|
||||
fi
|
||||
|
||||
base_json_version=""
|
||||
if git cat-file -e "${BASE_SHA}:${json_path}" 2>/dev/null; then
|
||||
base_json_version="$(git show "${BASE_SHA}:${json_path}" | jq -r '.version // empty' 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
base_md_version="$(get_md_version_from_git "${BASE_SHA}" "${md_path}")"
|
||||
|
||||
json_version_changed=false
|
||||
md_version_changed=false
|
||||
|
||||
if [ "${head_json_version}" != "${base_json_version}" ]; then
|
||||
json_version_changed=true
|
||||
fi
|
||||
|
||||
if [ "${head_md_version}" != "${base_md_version}" ]; then
|
||||
md_version_changed=true
|
||||
fi
|
||||
|
||||
if [ "${json_version_changed}" != "true" ] && [ "${md_version_changed}" != "true" ]; then
|
||||
echo "No version bump detected for ${skill_dir}; skipping dry-run."
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -z "${head_json_version}" ] || [ -z "${head_md_version}" ] || [ "${head_json_version}" != "${head_md_version}" ]; then
|
||||
echo "::error file=${skill_dir}::Version metadata is invalid for dry-run. Ensure validate-pr-version-sync passes."
|
||||
failures=$((failures + 1))
|
||||
@@ -493,7 +533,7 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ "${dry_run_count}" -eq 0 ]; then
|
||||
echo "No changed skills found for dry-run."
|
||||
echo "No version bumps detected in changed skill metadata files."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -599,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
|
||||
|
||||
@@ -812,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
|
||||
@@ -828,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:
|
||||
@@ -858,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
|
||||
@@ -963,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 != ''
|
||||
@@ -1075,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: |
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Sync Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'wiki/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: wiki-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-wiki:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Sync wiki folder to repository wiki
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -d wiki ]; then
|
||||
echo "::error::wiki/ directory not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# GitHub Wiki root (/wiki) renders Home.md, not INDEX.md.
|
||||
# INDEX.md is the canonical source; generate Home.md from it.
|
||||
if [ ! -f wiki/INDEX.md ]; then
|
||||
echo "::error::wiki/INDEX.md not found. It is required to generate wiki/Home.md."
|
||||
exit 1
|
||||
fi
|
||||
cp wiki/INDEX.md wiki/Home.md
|
||||
|
||||
WIKI_REMOTE="https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.wiki.git"
|
||||
if ! git ls-remote "$WIKI_REMOTE" >/dev/null 2>&1; then
|
||||
echo "::warning::Wiki remote unavailable (repository wiki may be disabled). Skipping sync."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WIKI_TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$WIKI_TMP"' EXIT
|
||||
|
||||
git clone --depth 1 "$WIKI_REMOTE" "$WIKI_TMP"
|
||||
rsync -a --delete --exclude '.git/' wiki/ "$WIKI_TMP/"
|
||||
|
||||
cd "$WIKI_TMP"
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "No wiki changes to sync."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WIKI_HEAD_REF="$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
||||
if [ -n "$WIKI_HEAD_REF" ]; then
|
||||
WIKI_BRANCH="${WIKI_HEAD_REF#origin/}"
|
||||
else
|
||||
WIKI_BRANCH="master"
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git commit -m "docs(wiki): sync from ${GITHUB_SHA}"
|
||||
# Clone may sanitize credentials from origin URL; push with explicit auth URL.
|
||||
git push "$WIKI_REMOTE" HEAD:"$WIKI_BRANCH"
|
||||
+12
@@ -1,4 +1,5 @@
|
||||
.claude
|
||||
.auto-claude/
|
||||
.codex
|
||||
_bmad
|
||||
_bmad-output
|
||||
@@ -24,6 +25,7 @@ dist-ssr
|
||||
# Derived public assets (copied during build)
|
||||
public/advisories
|
||||
public/skills
|
||||
public/wiki/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
@@ -39,3 +41,13 @@ __pycache__/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
clawsec-signing-private.pem
|
||||
|
||||
# Auto Claude generated files
|
||||
.auto-claude/
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.claude_settings.json
|
||||
.worktrees/
|
||||
.security-key
|
||||
logs/security/
|
||||
|
||||
@@ -1,12 +1,63 @@
|
||||
- Delete unused or obsolete files when your changes make them irrelevant (refactors, feature removals, etc.), and revert files only when the change is yours or explicitly requested. If a git operation leaves you unsure about other agents' in-flight work, stop and coordinate instead of deleting.
|
||||
- **Before attempting to delete a file to resolve a local type/lint failure, stop and ask the user.** Other agents are often editing adjacent files; deleting their work to silence an error is never acceptable without explicit approval.
|
||||
- NEVER edit `.env` or any environment variable files—only the user may change them.
|
||||
- Coordinate with other agents before removing their in-progress edits—don't revert or delete work you didn't author unless everyone agrees.
|
||||
- Moving/renaming and restoring files is allowed.
|
||||
- ABSOLUTELY NEVER run destructive git operations (e.g., `git reset --hard`, `rm`, `git checkout`/`git restore` to an older commit) unless the user gives an explicit, written instruction in this conversation. Treat these commands as catastrophic; if you are even slightly unsure, stop and ask before touching them. *(When working within Cursor or Codex Web, these git limitations do not apply; use the tooling's capabilities as needed.)*
|
||||
- Never use `git restore` (or similar commands) to revert files you didn't author—coordinate with other agents instead so their in-progress work stays intact.
|
||||
- Always double-check git status before any commit
|
||||
- Keep commits atomic: commit only the files you touched and list each path explicitly. For tracked files run `git commit -m "<scoped message>" -- path/to/file1 path/to/file2`. For brand-new files, use the one-liner `git restore --staged :/ && git add "path/to/file1" "path/to/file2" && git commit -m "<scoped message>" -- path/to/file1 path/to/file2`.
|
||||
- Quote any git paths containing brackets or parentheses (e.g., `src/app/[candidate]/**`) when staging or committing so the shell does not treat them as globs or subshells.
|
||||
- When running `git rebase`, avoid opening editors—export `GIT_EDITOR=:` and `GIT_SEQUENCE_EDITOR=:` (or pass `--no-edit`) so the default messages are used automatically.
|
||||
- Never amend commits unless you have explicit written approval in the task thread.
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
ClawSec combines a Vite + React frontend with security skill packages and release tooling.
|
||||
- Frontend entrypoints: `index.tsx`, `App.tsx`
|
||||
- UI and routes: `components/`, `pages/`
|
||||
- Shared types/constants: `types.ts`, `constants.ts`
|
||||
- Wiki source docs: `wiki/` (synced to GitHub Wiki by `.github/workflows/wiki-sync.yml`)
|
||||
- Generated wiki exports: `public/wiki/` (`llms.txt` outputs; generated locally/CI and gitignored)
|
||||
- Skills: `skills/<skill-name>/` (`skill.json`, `SKILL.md`, optional `scripts/`, `test/`)
|
||||
- Advisory feed: `advisories/feed.json`, `advisories/feed.json.sig`
|
||||
- Automation: `scripts/`, `.github/workflows/`
|
||||
- Python utilities: `utils/validate_skill.py`, `utils/package_skill.py`
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm install`: install dependencies.
|
||||
- `npm run dev`: run local Vite server.
|
||||
- `npm run build`: create production build (CI gate).
|
||||
- `npm run preview`: preview built app.
|
||||
- `npm run gen:wiki-llms`: generate wiki `llms.txt` exports from `wiki/` into `public/wiki/`.
|
||||
- `./scripts/prepare-to-push.sh [--fix]`: run lint, types, build, and security checks.
|
||||
- `./scripts/populate-local-wiki.sh`: regenerate local wiki `llms.txt` exports for preview.
|
||||
- `npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0`: lint JS/TS.
|
||||
- `npx tsc --noEmit`: type-check TypeScript.
|
||||
- `node skills/clawsec-suite/test/feed_verification.test.mjs`: run a skill-local Node test.
|
||||
- `python utils/validate_skill.py skills/<skill-name>`: validate skill schema/metadata.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Use TypeScript/TSX for frontend code and ESM for scripts.
|
||||
- Follow `eslint.config.js`; prefix intentionally unused vars/args with `_`.
|
||||
- Python under `utils/` follows `pyproject.toml` Ruff/Bandit rules (line length 120).
|
||||
- Name React files in PascalCase (for example, `SkillCard.tsx`), skill directories in kebab-case (for example, `skills/clawsec-feed`), and tests as `*.test.mjs`.
|
||||
|
||||
## Testing Guidelines
|
||||
There is no root `npm test`; tests are mostly skill-local.
|
||||
- Run changed tests directly: `node skills/<skill>/test/<name>.test.mjs`.
|
||||
- For frontend/config changes, run ESLint, `npx tsc --noEmit`, and `npm run build`.
|
||||
- For wiki rendering/export changes, run `npm run gen:wiki-llms` and `npm run build`.
|
||||
- For Python utility updates, run `ruff check utils/` and `bandit -r utils/ -ll`.
|
||||
|
||||
## Pull Request Guidelines
|
||||
- Follow Conventional Commits: `feat(scope): ...`, `fix(scope): ...`, `chore(scope): ...`.
|
||||
- Use skill branches like `skill/<name>-...`.
|
||||
- Keep PRs focused and include summary, security benefit, and testing performed.
|
||||
- Keep versions aligned between `skills/<skill>/skill.json` and `skills/<skill>/SKILL.md`.
|
||||
- Do not push release tags from PR branches; releases are tagged from `main`.
|
||||
- Do not commit generated `public/wiki/` artifacts; edit `wiki/` source files instead.
|
||||
|
||||
## Agent Collaboration & Git Safety
|
||||
- Delete unused or obsolete files only when your changes make them irrelevant; revert files only when the change is yours or explicitly requested. If a git operation creates uncertainty about another agent’s in-flight work, stop and coordinate instead of deleting.
|
||||
- Before deleting any file to fix local type/lint failures, stop and ask the user.
|
||||
- Never edit `.env` or any environment variable files.
|
||||
- Coordinate with other agents before removing their in-progress edits; do not revert or delete work you did not author unless everyone agrees.
|
||||
- Moving, renaming, and restoring files is allowed when done safely.
|
||||
- Never run destructive git operations without explicit written instruction in this conversation: `git reset --hard`, `rm`, `git checkout`/`git restore` to older commits. Treat these as catastrophic; if unsure, stop and ask. In Cursor or Codex Web, use platform tooling as applicable.
|
||||
- Never use `git restore` (or similar revert commands) on files you did not author.
|
||||
- Always run `git status` before committing.
|
||||
- Keep commits atomic and commit only touched files with explicit paths.
|
||||
- For tracked files: `git commit -m "<scoped message>" -- path/to/file1 path/to/file2`.
|
||||
- For new files: `git restore --staged :/ && git add "path/to/file1" "path/to/file2" && git commit -m "<scoped message>" -- path/to/file1 path/to/file2`.
|
||||
- Quote any git path containing brackets or parentheses when staging/committing (for example, `"src/app/[candidate]/**"`).
|
||||
- For rebases, avoid editors: `GIT_EDITOR=:` and `GIT_SEQUENCE_EDITOR=:` (or `--no-edit`).
|
||||
- Never amend commits without explicit written approval in this task thread.
|
||||
|
||||
@@ -6,6 +6,8 @@ import { FeedSetup } from './pages/FeedSetup';
|
||||
import { SkillsCatalog } from './pages/SkillsCatalog';
|
||||
import { SkillDetail } from './pages/SkillDetail';
|
||||
import { AdvisoryDetail } from './pages/AdvisoryDetail';
|
||||
import { WikiBrowser } from './pages/WikiBrowser';
|
||||
import { ProductDemo } from './pages/ProductDemo';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
@@ -17,10 +19,12 @@ const App: React.FC = () => {
|
||||
<Route path="/skills/:skillId" element={<SkillDetail />} />
|
||||
<Route path="/feed" element={<FeedSetup />} />
|
||||
<Route path="/feed/:advisoryId" element={<AdvisoryDetail />} />
|
||||
<Route path="/demo" element={<ProductDemo />} />
|
||||
<Route path="/wiki/*" element={<WikiBrowser />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
+9
-4
@@ -2,8 +2,13 @@
|
||||
|
||||
Thank you for your interest in contributing security skills to the ClawSec ecosystem! This guide will walk you through creating, testing, and submitting new skills.
|
||||
|
||||
## Wiki Documentation Source of Truth
|
||||
|
||||
For contributor-facing wiki docs, treat `wiki/` in this repository as the single source of truth. Do not edit the GitHub Wiki directly; `.github/workflows/wiki-sync.yml` publishes `wiki/` to `<repo>.wiki.git` when `wiki/**` changes on `main`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Wiki Documentation Source of Truth](#wiki-documentation-source-of-truth)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Skill Structure](#skill-structure)
|
||||
- [Creating a New Skill](#creating-a-new-skill)
|
||||
@@ -116,7 +121,7 @@ Create `skill.json` with the following structure:
|
||||
"version": "0.0.1",
|
||||
"description": "Brief description of what your skill does",
|
||||
"author": "your-github-username",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://github.com/prompt-security/clawsec",
|
||||
"keywords": ["security", "relevant", "tags"],
|
||||
|
||||
@@ -206,7 +211,7 @@ Add any additional files your skill needs (configs, templates, scripts), and **e
|
||||
| `version` | string | Semantic version (0.0.1) |
|
||||
| `description` | string | Brief description (max 200 chars) |
|
||||
| `author` | string | Your GitHub username or organization |
|
||||
| `license` | string | License type (prefer MIT) |
|
||||
| `license` | string | License type (prefer AGPL-3.0-or-later) |
|
||||
| `homepage` | string | Repository URL |
|
||||
| `keywords` | array | Searchable tags |
|
||||
| `sbom` | object | Software Bill of Materials |
|
||||
@@ -488,7 +493,7 @@ cat > skills/simple-scanner/skill.json << 'EOF'
|
||||
"version": "0.0.1",
|
||||
"description": "Basic security scanner for AI agents",
|
||||
"author": "contributor-name",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://github.com/prompt-security/clawsec",
|
||||
"keywords": ["security", "scanner", "basic"],
|
||||
"sbom": {
|
||||
@@ -649,7 +654,7 @@ Wait for a verified patched version.
|
||||
|
||||
Once your advisory is published:
|
||||
|
||||
1. **Agents receive it** - The feed is served from raw GitHub, so agents see it on their next feed check
|
||||
1. **Agents receive it** - The feed is served at `https://clawsec.prompt.security/advisories/feed.json` (with signature/checksum artifacts), so agents see it on their next feed check
|
||||
2. **You're credited** - Your issue is linked in the advisory
|
||||
3. **Community is protected** - Agents using ClawSec Feed will be alerted
|
||||
|
||||
|
||||
@@ -1,21 +1,661 @@
|
||||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2026 Prompt Security, SentinelOne
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Secure Your OpenClaw Bots with a Complete Security Skill Suite
|
||||
## Secure Your OpenClaw and NanoClaw Agents with a Complete Security Skill Suite
|
||||
|
||||
<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4>
|
||||
|
||||
@@ -33,7 +33,12 @@
|
||||
|
||||
## 🦞 What is ClawSec?
|
||||
|
||||
ClawSec is a **complete security skill suite for the OpenClaw family of agents (Moltbot, Clawdbot, some clones)**. It provides a unified installer that deploys, verifies, and maintains security skills-protecting your agent's cognitive architecture against prompt injection, drift, and malicious instructions.
|
||||
ClawSec is a **complete security skill suite for AI agent platforms**. It provides unified security monitoring, integrity verification, and threat intelligence-protecting your agent's cognitive architecture against prompt injection, drift, and malicious instructions.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
- **OpenClaw** (MoltBot, Clawdbot, and clones) - Full suite with skill installer, file integrity protection, and security audits
|
||||
- **NanoClaw** - Containerized WhatsApp bot security with MCP tools for advisory monitoring, signature verification, and file integrity
|
||||
|
||||
### Core Capabilities
|
||||
|
||||
@@ -46,45 +51,128 @@ ClawSec is a **complete security skill suite for the OpenClaw family of agents (
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Product Demos
|
||||
|
||||
Animated previews below are GIFs (no audio). Click any preview to open the full MP4 with audio.
|
||||
|
||||
### Install Demo (`clawsec-suite`)
|
||||
|
||||
[](public/video/install-demo.mp4)
|
||||
|
||||
Direct link: [install-demo.mp4](public/video/install-demo.mp4)
|
||||
|
||||
### Drift Detection Demo (`soul-guardian`)
|
||||
|
||||
[](public/video/soul-guardian-demo.mp4)
|
||||
|
||||
Direct link: [soul-guardian-demo.mp4](public/video/soul-guardian-demo.mp4)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### For AI Agents
|
||||
|
||||
```bash
|
||||
# Fetch and install the ClawSec security suite
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
# Install the ClawSec security suite
|
||||
npx clawhub@latest install clawsec-suite
|
||||
```
|
||||
|
||||
The skill file contains deployment instructions. Your agent will:
|
||||
1. Detect its agent family (OpenClaw/MoltBot/ClawdBot or other)
|
||||
2. Install appropriate skills from the catalog
|
||||
3. Verify integrity using checksums
|
||||
4. Set up cron update checks
|
||||
After install, the suite can:
|
||||
1. Discover installable protections from the published skills catalog
|
||||
2. Verify release integrity using signed checksums
|
||||
3. Set up advisory monitoring and hook-based protection flows
|
||||
4. Add optional scheduled checks
|
||||
|
||||
Manual/source-first option:
|
||||
|
||||
> Read https://github.com/prompt-security/clawsec/releases/latest/download/SKILL.md and follow the installation instructions.
|
||||
|
||||
### For Humans
|
||||
|
||||
Copy this instruction to your AI agent:
|
||||
|
||||
> Read https://clawsec.prompt.security/releases/latest/download/SKILL.md and follow the instructions to install the protection skill suite.
|
||||
> Install ClawSec with `npx clawhub@latest install clawsec-suite`, then complete the setup steps from the generated instructions.
|
||||
|
||||
### Shell and OS Notes
|
||||
|
||||
ClawSec scripts are split between:
|
||||
- Cross-platform Node/Python tooling (`npm run build`, hook/setup `.mjs`, `utils/*.py`)
|
||||
- POSIX shell workflows (`*.sh`, most manual install snippets)
|
||||
|
||||
For Linux/macOS (`bash`/`zsh`):
|
||||
- Use unquoted or double-quoted home vars: `export INSTALL_ROOT="$HOME/.openclaw/skills"`
|
||||
- Do **not** single-quote expandable vars (for example, avoid `'$HOME/.openclaw/skills'`)
|
||||
|
||||
For Windows (PowerShell):
|
||||
- Prefer explicit path building:
|
||||
- `$env:INSTALL_ROOT = Join-Path $HOME ".openclaw\\skills"`
|
||||
- `node "$env:INSTALL_ROOT\\clawsec-suite\\scripts\\setup_advisory_hook.mjs"`
|
||||
- POSIX `.sh` scripts require WSL or Git Bash.
|
||||
|
||||
Troubleshooting: if you see directories such as `~/.openclaw/workspace/$HOME/...`, a home variable was passed literally. Re-run using an absolute path or an unquoted home expression.
|
||||
|
||||
---
|
||||
|
||||
## 📦 ClawSec Suite
|
||||
## 📱 NanoClaw Platform Support
|
||||
|
||||
ClawSec now supports **NanoClaw**, a containerized WhatsApp bot powered by Claude agents.
|
||||
|
||||
### clawsec-nanoclaw Skill
|
||||
|
||||
**Location**: `skills/clawsec-nanoclaw/`
|
||||
|
||||
A complete security suite adapted for NanoClaw's containerized architecture:
|
||||
|
||||
- **9 MCP Tools** for agents to check vulnerabilities
|
||||
- Advisory checking and browsing
|
||||
- Pre-installation safety checks
|
||||
- Skill package signature verification (Ed25519)
|
||||
- File integrity monitoring
|
||||
- **Automatic Advisory Feed** - Fetches and caches advisories every 6 hours
|
||||
- **Platform Filtering** - Shows only NanoClaw-relevant advisories
|
||||
- **IPC-Based** - Container-safe host communication
|
||||
- **Full Documentation** - Installation guide, usage examples, troubleshooting
|
||||
|
||||
### Advisory Feed for NanoClaw
|
||||
|
||||
The feed now monitors NanoClaw-specific keywords:
|
||||
- `NanoClaw` - Direct product name
|
||||
- `WhatsApp-bot` - Core functionality
|
||||
- `baileys` - WhatsApp client library dependency
|
||||
|
||||
Advisories can specify `platforms: ["nanoclaw"]` for platform-specific issues.
|
||||
|
||||
### Quick Start for NanoClaw
|
||||
|
||||
See [`skills/clawsec-nanoclaw/INSTALL.md`](skills/clawsec-nanoclaw/INSTALL.md) for detailed setup instructions.
|
||||
|
||||
**Quick integration:**
|
||||
1. Copy skill to NanoClaw deployment
|
||||
2. Integrate MCP tools in container
|
||||
3. Add IPC handlers and cache service on host
|
||||
4. Restart NanoClaw
|
||||
|
||||
---
|
||||
|
||||
## 📦 ClawSec Suite (OpenClaw)
|
||||
|
||||
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 |
|
||||
|-------|-------------|--------------|---------------|
|
||||
| 📡 **clawsec-feed** | Security advisory feed monitoring with live CVE updates | ✅ Included by default | All agents |
|
||||
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with email reporting | ⚙️ Optional (install separately) | OpenClaw/MoltBot/ClawdBot |
|
||||
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with email reporting | ⚙️ Optional (install separately) | OpenClaw/MoltBot/Clawdbot |
|
||||
| 👻 **soul-guardian** | Drift detection and file integrity guard with auto-restore | ⚙️ Optional | All agents |
|
||||
| 🤝 **clawtributor** | Community incident reporting | ❌ Optional (Explicit request) | All agents |
|
||||
|
||||
> ⚠️ **clawtributor** is not installed by default as it may share anonymized incident data. Install only on explicit user request.
|
||||
|
||||
> ⚠️ **openclaw-audit-watchdog** is tailored for the OpenClaw/MoltBot/ClawdBot agent family. Other agents receive the universal skill set.
|
||||
> ⚠️ **openclaw-audit-watchdog** is tailored for the OpenClaw/MoltBot/Clawdbot agent family. Other agents receive the universal skill set.
|
||||
|
||||
### Suite Features
|
||||
|
||||
@@ -106,15 +194,28 @@ ClawSec maintains a continuously updated security advisory feed, automatically p
|
||||
curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[] | select(.severity == "critical" or .severity == "high")'
|
||||
```
|
||||
|
||||
Canonical endpoint: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
Compatibility mirror (legacy): `https://clawsec.prompt.security/releases/latest/download/feed.json`
|
||||
|
||||
### Monitored Keywords
|
||||
|
||||
The feed polls CVEs related to:
|
||||
- `OpenClaw`
|
||||
- `clawdbot`
|
||||
- `Moltbot`
|
||||
- **OpenClaw Platform**: `OpenClaw`, `clawdbot`, `Moltbot`
|
||||
- **NanoClaw Platform**: `NanoClaw`, `WhatsApp-bot`, `baileys`
|
||||
- Prompt injection patterns
|
||||
- Agent security vulnerabilities
|
||||
|
||||
### Exploitability Context
|
||||
|
||||
ClawSec enriches CVE advisories with **exploitability context** to help agents assess real-world risk beyond raw CVSS scores. Newly analyzed advisories can include:
|
||||
|
||||
- **Exploit Evidence**: Whether public exploits exist in the wild
|
||||
- **Weaponization Status**: If exploits are integrated into common attack frameworks
|
||||
- **Attack Requirements**: Prerequisites needed for successful exploitation (network access, authentication, user interaction)
|
||||
- **Risk Assessment**: Contextualized risk level combining technical severity with exploitability
|
||||
|
||||
This feature helps agents prioritize vulnerabilities that pose immediate threats versus theoretical risks, enabling smarter security decisions.
|
||||
|
||||
### Advisory Schema
|
||||
|
||||
**NVD CVE Advisory:**
|
||||
@@ -123,11 +224,14 @@ The feed polls CVEs related to:
|
||||
"id": "CVE-2026-XXXXX",
|
||||
"severity": "critical|high|medium|low",
|
||||
"type": "vulnerable_skill",
|
||||
"platforms": ["openclaw", "nanoclaw"],
|
||||
"title": "Short description",
|
||||
"description": "Full CVE description from NVD",
|
||||
"published": "2026-02-01T00:00:00Z",
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-XXXXX",
|
||||
"exploitability_score": "high|medium|low|unknown",
|
||||
"exploitability_rationale": "Why this CVE is or is not likely exploitable in agent deployments",
|
||||
"references": ["..."],
|
||||
"action": "Recommended remediation"
|
||||
}
|
||||
@@ -139,6 +243,7 @@ The feed polls CVEs related to:
|
||||
"id": "CLAW-2026-0042",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection|vulnerable_skill|tampering_attempt",
|
||||
"platforms": ["nanoclaw"],
|
||||
"title": "Short description",
|
||||
"description": "Detailed description from issue",
|
||||
"published": "2026-02-01T00:00:00Z",
|
||||
@@ -149,6 +254,12 @@ The feed polls CVEs related to:
|
||||
}
|
||||
```
|
||||
|
||||
**Platform values:**
|
||||
- `"openclaw"` - OpenClaw/Clawdbot/MoltBot only
|
||||
- `"nanoclaw"` - NanoClaw only
|
||||
- `["openclaw", "nanoclaw"]` - Both platforms
|
||||
- (empty/missing) - All platforms (backward compatible)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 CI/CD Pipelines
|
||||
@@ -159,10 +270,13 @@ ClawSec uses automated pipelines for continuous security updates and skill distr
|
||||
|
||||
| Workflow | Trigger | Description |
|
||||
|----------|---------|-------------|
|
||||
| **ci.yml** | PRs to `main`, pushes to `main` | Lint/type/build + skill test suites |
|
||||
| **pages-verify.yml** | PRs to `main` | Verifies Pages build and signing outputs without publishing |
|
||||
| **poll-nvd-cves.yml** | Daily cron (06:00 UTC) | Polls NVD for new CVEs, updates feed |
|
||||
| **community-advisory.yml** | Issue labeled `advisory-approved` | Processes community reports into advisories |
|
||||
| **skill-release.yml** | `<skill>-v*.*.*` tags | Packages individual skills with checksums to GitHub Releases |
|
||||
| **deploy-pages.yml** | Push to main | Builds and deploys the web interface to GitHub Pages |
|
||||
| **skill-release.yml** | Skill tags + metadata PR changes | Validates version parity in PRs and publishes signed skill releases on tags |
|
||||
| **deploy-pages.yml** | `workflow_run` after successful trusted CI/release or manual dispatch | Builds and deploys the web interface to GitHub Pages |
|
||||
| **wiki-sync.yml** | Pushes to `main` touching `wiki/**` | Syncs `wiki/` to the GitHub Wiki mirror |
|
||||
|
||||
### Skill Release Pipeline
|
||||
|
||||
@@ -173,7 +287,7 @@ When a skill is tagged (e.g., `soul-guardian-v1.0.0`), the pipeline:
|
||||
3. **Generates Checksums** - Creates `checksums.json` with SHA256 hashes for all SBOM files
|
||||
4. **Signs + verifies** - Signs `checksums.json` and validates the generated `signing-public.pem` fingerprint against canonical repo key material
|
||||
5. **Releases** - Publishes to GitHub Releases with all artifacts
|
||||
6. **Supersedes Old Releases** - Marks older versions (same major) as pre-releases
|
||||
6. **Supersedes Old Releases** - Deletes older versions within the same major line (tags remain)
|
||||
7. **Triggers Pages Update** - Refreshes the skills catalog on the website
|
||||
|
||||
### Signing Key Consistency Guardrails
|
||||
@@ -224,8 +338,8 @@ Each skill release includes:
|
||||
### Signing Operations Documentation
|
||||
|
||||
For feed/release signing rollout and operations guidance:
|
||||
- [`SECURITY-SIGNING.md`](SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
|
||||
- [`MIGRATION-SIGNED-FEED.md`](MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan
|
||||
- [`wiki/security-signing-runbook.md`](wiki/security-signing-runbook.md) - key generation, GitHub secrets, rotation/revocation, incident response
|
||||
- [`wiki/migration-signed-feed.md`](wiki/migration-signed-feed.md) - phased migration from unsigned feed, enforcement gates, rollback plan
|
||||
|
||||
---
|
||||
|
||||
@@ -286,8 +400,18 @@ npm run dev
|
||||
|
||||
# Populate advisory feed with real NVD CVE data
|
||||
./scripts/populate-local-feed.sh --days 120
|
||||
|
||||
# Generate wiki llms exports from wiki/ (for local preview)
|
||||
./scripts/populate-local-wiki.sh
|
||||
|
||||
# Direct generator entrypoint (used by predev/prebuild)
|
||||
npm run gen:wiki-llms
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `npm run dev` and `npm run build` automatically regenerate wiki `llms.txt` exports (`predev`/`prebuild` hooks).
|
||||
- `public/wiki/` is generated output (local + CI) and is intentionally gitignored.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
@@ -303,24 +427,35 @@ npm run build
|
||||
│ └── feed.json # Main advisory feed (auto-updated from NVD)
|
||||
├── components/ # React components
|
||||
├── pages/ # Page components
|
||||
├── wiki/ # Source-of-truth docs (synced to GitHub Wiki)
|
||||
├── scripts/
|
||||
│ ├── generate-wiki-llms.mjs # wiki/*.md -> public/wiki/**/llms.txt
|
||||
│ ├── populate-local-feed.sh # Local CVE feed populator
|
||||
│ ├── populate-local-skills.sh # Local skills catalog populator
|
||||
│ ├── 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
|
||||
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
|
||||
│ ├── prompt-agent/ # 🧠 Prompt-focused protection workflows
|
||||
│ └── soul-guardian/ # 👻 File integrity skill
|
||||
├── utils/
|
||||
│ ├── package_skill.py # Skill packager utility
|
||||
│ └── validate_skill.py # Skill validator utility
|
||||
├── .github/workflows/
|
||||
│ ├── ci.yml # Cross-platform lint/type/build + tests
|
||||
│ ├── pages-verify.yml # PR-only pages build verification
|
||||
│ ├── poll-nvd-cves.yml # CVE polling pipeline
|
||||
│ ├── community-advisory.yml # Approved issue -> advisory PR
|
||||
│ ├── skill-release.yml # Skill release pipeline
|
||||
│ ├── wiki-sync.yml # Sync repo wiki/ to GitHub Wiki
|
||||
│ └── deploy-pages.yml # Pages deployment
|
||||
└── public/ # Static assets and published skills
|
||||
└── public/ # Static assets + generated publish artifacts
|
||||
```
|
||||
|
||||
---
|
||||
@@ -348,11 +483,19 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#submitting-security-advisories) for detail
|
||||
4. Validate with `python utils/validate_skill.py skills/your-skill`
|
||||
5. Submit a PR for review
|
||||
|
||||
## 📚 Documentation Source of Truth
|
||||
|
||||
For all wiki content, edit files under `wiki/` in this repository. The GitHub Wiki (`<repo>.wiki.git`) is synced from `wiki/` by `.github/workflows/wiki-sync.yml` when `wiki/**` changes on `main`.
|
||||
|
||||
LLM exports are generated from `wiki/` into `public/wiki/`:
|
||||
- `/wiki/llms.txt` is the LLM-ready export for `wiki/INDEX.md` (or a generated fallback index if `INDEX.md` is missing).
|
||||
- `/wiki/<page>/llms.txt` is the LLM-ready export for that single wiki page.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
- Source code: MIT License - See [LICENSE](LICENSE) for details.
|
||||
- Source code: GNU AGPL v3.0 or later - See [LICENSE](LICENSE) for details.
|
||||
- Fonts in `font/`: Licensed separately - See [`font/README.md`](font/README.md).
|
||||
|
||||
---
|
||||
|
||||
+2808
-11
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
t39IWpreVBdG2SDMBYrKw3On1UlrimlglhnIiBzvfXTV2gBvxOI815tHsGqfMWsRTvZ6gqbTO1njQy44392pBQ==
|
||||
@@ -4,7 +4,7 @@ export const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="text-center py-6 mt-auto">
|
||||
<p className="text-gray-300 text-sm italic">
|
||||
ClawSec is a project by Prompt Security, a SentinelOne company. It's not affiliated with OpenClaw. Designed for security research and agentic workflow hardening.
|
||||
ClawSec is a project by Prompt Security, a SentinelOne company. It's not affiliated with OpenClaw or NanoClaw. Designed for security research and agentic workflow hardening.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4">
|
||||
<span className="text-2xl animate-pulse">🦞</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Menu, X, Terminal, Layers, Rss, Home, Github } from 'lucide-react';
|
||||
import { Menu, X, Terminal, Layers, Rss, Home, Github, BookOpenText, PlayCircle } from 'lucide-react';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -9,6 +9,8 @@ export const Header: React.FC = () => {
|
||||
{ label: 'Home', path: '/', icon: Home },
|
||||
{ label: 'Skills', path: '/skills', icon: Layers },
|
||||
{ label: 'Security Feed', path: '/feed', icon: Rss },
|
||||
{ label: 'Product Demo', path: '/demo', icon: PlayCircle },
|
||||
{ label: 'Wiki', path: '/wiki', icon: BookOpenText },
|
||||
];
|
||||
|
||||
const baseLink =
|
||||
|
||||
+5
-3
@@ -1,7 +1,9 @@
|
||||
|
||||
// Feed URL for fetching live advisories
|
||||
export const ADVISORY_FEED_URL = 'https://clawsec.prompt.security/releases/latest/download/feed.json';
|
||||
// Canonical hosted feed endpoint for fetching live advisories
|
||||
export const ADVISORY_FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json';
|
||||
|
||||
// Compatibility mirror for legacy clients; keep as last-resort fallback only
|
||||
export const LEGACY_ADVISORY_FEED_URL = 'https://clawsec.prompt.security/releases/latest/download/feed.json';
|
||||
|
||||
// Local feed path for development
|
||||
export const LOCAL_FEED_PATH = '/advisories/feed.json';
|
||||
|
||||
|
||||
+13
-4
@@ -1,3 +1,7 @@
|
||||
// NOTE: @eslint/js is pinned to ~9.x because v10 introduces a peerOptional
|
||||
// dependency on eslint@^10, and the typescript-eslint / react plugin ecosystem
|
||||
// hasn't published eslint-10-compatible releases yet. Upgrade @eslint/js to ^10
|
||||
// once @typescript-eslint and eslint-plugin-react declare eslint@^10 support.
|
||||
import js from '@eslint/js';
|
||||
import typescript from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
@@ -24,6 +28,7 @@ export default [
|
||||
navigator: 'readonly',
|
||||
fetch: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
URL: 'readonly',
|
||||
@@ -31,10 +36,13 @@ export default [
|
||||
HTMLElement: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
// Node.js globals (for Vite config, build scripts)
|
||||
// Node.js globals (for Vite config, build scripts, and skill modules)
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly'
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
RequestInit: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
@@ -77,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)
|
||||
@@ -105,6 +114,6 @@ export default [
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js', 'public/']
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js', 'public/', '.venv/']
|
||||
}
|
||||
];
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
|
||||
This repository includes the **Prometo** font files in `font/`.
|
||||
|
||||
These font binaries are **not covered by the repository MIT license**. They are used under the applicable **Adobe Fonts / Dalton Maag** licensing terms for Prompt Security / SentinelOne. Do not redistribute or reuse them outside the terms of that license.
|
||||
These font binaries are **not covered by the repository AGPL license**. They are used under the applicable **Adobe Fonts / Dalton Maag** licensing terms for Prompt Security / SentinelOne. Do not redistribute or reuse them outside the terms of that license.
|
||||
|
||||
If you are forking or redistributing this project and you do not have the appropriate rights, remove `font/Prometo_Trial_*.ttf` and update the CSS/font stack accordingly.
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "ClawSec",
|
||||
"description": "A security-first skill distribution platform for OpenClaw agents (and some clones), featuring verified audit skills, hardening feeds, and guardian mode protocols."
|
||||
}
|
||||
"description": "A security-first skill distribution platform for OpenClaw and NanoClaw agents, featuring verified audit skills, hardening feeds, and guardian mode protocols."
|
||||
}
|
||||
|
||||
Generated
+820
-428
File diff suppressed because it is too large
Load Diff
+20
-9
@@ -1,32 +1,43 @@
|
||||
{
|
||||
"name": "ClawSec",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"gen:wiki-llms": "node scripts/generate-wiki-llms.mjs",
|
||||
"populate-local-wiki": "./scripts/populate-local-wiki.sh",
|
||||
"predev": "npm run gen:wiki-llms",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run gen:wiki-llms",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.563.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^22.14.0",
|
||||
"@eslint/js": "~9.28.0",
|
||||
"@types/node": "^25.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.55.0",
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
"fast-check": "^4.5.3",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
"ajv": "6.14.0",
|
||||
"balanced-match": "4.0.3",
|
||||
"brace-expansion": "5.0.2",
|
||||
"minimatch": "10.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, ExternalLink, Shield, AlertTriangle, Github, User, Bot } from 'lucide-react';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { Advisory, AdvisoryFeed } from '../types';
|
||||
import { ADVISORY_FEED_URL, LOCAL_FEED_PATH } from '../constants';
|
||||
import {
|
||||
ADVISORY_FEED_URL,
|
||||
LEGACY_ADVISORY_FEED_URL,
|
||||
LOCAL_FEED_PATH,
|
||||
} from '../constants';
|
||||
|
||||
export const AdvisoryDetail: React.FC = () => {
|
||||
const { advisoryId } = useParams<{ advisoryId: string }>();
|
||||
@@ -16,13 +20,17 @@ export const AdvisoryDetail: React.FC = () => {
|
||||
if (!advisoryId) return;
|
||||
|
||||
try {
|
||||
// Try local feed first (for development), then fall back to GitHub releases
|
||||
// Try local feed first (dev), then canonical hosted endpoint, then legacy mirror.
|
||||
let response = await fetch(LOCAL_FEED_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch(ADVISORY_FEED_URL);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch(LEGACY_ADVISORY_FEED_URL);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch feed: ${response.status}`);
|
||||
}
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export default function Checksums() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-clawd-700">
|
||||
{Object.entries(checksums.files).map(([filename, data]) => (
|
||||
{(Object.entries(checksums.files) as [string, FileChecksum][]).map(([filename, data]) => (
|
||||
<tr key={filename} className="hover:bg-clawd-700/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-mono text-sm text-clawd-accent">{filename}</div>
|
||||
|
||||
+75
-11
@@ -1,19 +1,59 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Rss, RefreshCw, Loader2, AlertTriangle, ChevronLeft, ChevronRight, Download, Users, AlertCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { AdvisoryCard } from '../components/AdvisoryCard';
|
||||
import { Advisory, AdvisoryFeed } from '../types';
|
||||
import { ADVISORY_FEED_URL, LOCAL_FEED_PATH } from '../constants';
|
||||
import {
|
||||
ADVISORY_FEED_URL,
|
||||
LEGACY_ADVISORY_FEED_URL,
|
||||
LOCAL_FEED_PATH,
|
||||
} from '../constants';
|
||||
|
||||
const ITEMS_PER_PAGE = 9;
|
||||
|
||||
const SEVERITY_TABS = [
|
||||
{ value: 'all', label: 'All', active: 'bg-clawd-accent text-white', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' },
|
||||
{ value: 'critical', label: 'Critical', active: 'bg-red-500/20 text-red-400 border-2 border-red-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-red-400/50' },
|
||||
{ value: 'high', label: 'High', active: 'bg-orange-500/20 text-orange-400 border-2 border-orange-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-orange-400/50' },
|
||||
{ value: 'medium', label: 'Medium', active: 'bg-yellow-500/20 text-yellow-400 border-2 border-yellow-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-yellow-400/50' },
|
||||
{ value: 'low', label: 'Low', active: 'bg-blue-500/20 text-blue-400 border-2 border-blue-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-blue-400/50' },
|
||||
] as const;
|
||||
|
||||
const PLATFORM_TABS = [
|
||||
{ value: 'all', label: 'All Platforms', active: 'bg-clawd-accent text-white', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' },
|
||||
{ value: 'openclaw', label: 'OpenClaw', active: 'bg-clawd-accent/20 text-clawd-accent border-2 border-clawd-accent', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' },
|
||||
{ value: 'nanoclaw', label: 'NanoClaw', active: 'bg-clawd-secondary/20 text-clawd-secondary border-2 border-clawd-secondary', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-secondary/50' },
|
||||
] as const;
|
||||
|
||||
const FilterTabs: React.FC<{
|
||||
tabs: ReadonlyArray<{ value: string; label: string; active: string; inactive: string }>;
|
||||
selected: string;
|
||||
onSelect: (value: string) => void;
|
||||
}> = ({ tabs, selected, onSelect }) => (
|
||||
<div className="flex flex-wrap justify-center gap-3 mb-8">
|
||||
{tabs.map(({ value, label, active, inactive }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onSelect(value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
selected === value ? active : inactive
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FeedSetup: React.FC = () => {
|
||||
const [advisories, setAdvisories] = useState<Advisory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedSeverity, setSelectedSeverity] = useState<string>('all');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAdvisories = async () => {
|
||||
@@ -21,13 +61,17 @@ export const FeedSetup: React.FC = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try local feed first (for development), then fall back to GitHub releases
|
||||
// Try local feed first (dev), then canonical hosted endpoint, then legacy mirror.
|
||||
let response = await fetch(LOCAL_FEED_PATH);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch(ADVISORY_FEED_URL);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch(LEGACY_ADVISORY_FEED_URL);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch feed: ${response.status}`);
|
||||
}
|
||||
@@ -47,6 +91,18 @@ export const FeedSetup: React.FC = () => {
|
||||
fetchAdvisories();
|
||||
}, []);
|
||||
|
||||
const filteredAdvisories = useMemo(
|
||||
() => advisories.filter((a) =>
|
||||
(selectedSeverity === 'all' || a.severity === selectedSeverity) &&
|
||||
(selectedPlatform === 'all' || !a.platforms?.length || a.platforms.includes(selectedPlatform))
|
||||
),
|
||||
[advisories, selectedSeverity, selectedPlatform],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [advisories, selectedSeverity, selectedPlatform]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
@@ -60,10 +116,10 @@ export const FeedSetup: React.FC = () => {
|
||||
};
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.ceil(advisories.length / ITEMS_PER_PAGE);
|
||||
const totalPages = Math.ceil(filteredAdvisories.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
const currentAdvisories = advisories.slice(startIndex, endIndex);
|
||||
const currentAdvisories = filteredAdvisories.slice(startIndex, endIndex);
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
@@ -76,7 +132,7 @@ export const FeedSetup: React.FC = () => {
|
||||
<h1 className="text-3xl md:text-4xl text-white">Security Hardening Feed</h1>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
A continuous stream of security advisories from NVD CVE data and staff-approved community reports.
|
||||
This feed is automatically updated with OpenClaw-related vulnerabilities and verified security incidents.
|
||||
This feed is automatically updated with OpenClaw and NanoClaw-related vulnerabilities and verified security incidents.
|
||||
</p>
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-gray-500">
|
||||
@@ -86,6 +142,9 @@ export const FeedSetup: React.FC = () => {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<FilterTabs tabs={SEVERITY_TABS} selected={selectedSeverity} onSelect={setSelectedSeverity} />
|
||||
<FilterTabs tabs={PLATFORM_TABS} selected={selectedPlatform} onSelect={setSelectedPlatform} />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-clawd-accent animate-spin" />
|
||||
@@ -96,9 +155,13 @@ export const FeedSetup: React.FC = () => {
|
||||
<AlertTriangle className="w-6 h-6 text-orange-400 mr-2" />
|
||||
<span className="text-gray-400">{error}</span>
|
||||
</div>
|
||||
) : advisories.length === 0 ? (
|
||||
) : filteredAdvisories.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">No security advisories at this time. Check back later.</p>
|
||||
<p className="text-gray-400">
|
||||
{advisories.length === 0
|
||||
? 'No security advisories at this time. Check back later.'
|
||||
: 'No advisories found for the selected filters.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -133,9 +196,10 @@ export const FeedSetup: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{advisories.length > 0 && (
|
||||
{filteredAdvisories.length > 0 && (
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
Showing {startIndex + 1}-{Math.min(endIndex, advisories.length)} of {advisories.length} advisories
|
||||
Showing {startIndex + 1}-{Math.min(endIndex, filteredAdvisories.length)} of {filteredAdvisories.length} advisories
|
||||
{(selectedSeverity !== 'all' || selectedPlatform !== 'all') && ` (${advisories.length} total)`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
+80
-11
@@ -1,14 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User, Bot, Copy, Check } from 'lucide-react';
|
||||
import { User, Bot, Copy, Check, Lock } from 'lucide-react';
|
||||
import { Footer } from '../components/Footer';
|
||||
|
||||
const FILE_NAMES = ['SOUL.md', 'AGENTS.md', 'USER.md', 'TOOLS.md', 'IDENTITY.md', 'HEARTBEAT.md', 'MEMORY.md'];
|
||||
const PLATFORM_NAMES = ['OpenClaw', 'NanoClaw'];
|
||||
const FILE_LOCK_REVEAL_DELAY_MS = 1600;
|
||||
|
||||
export const Home: React.FC = () => {
|
||||
const [isAgent, setIsAgent] = useState(true);
|
||||
const [copiedCurl, setCopiedCurl] = useState(false);
|
||||
const [copiedHuman, setCopiedHuman] = useState(false);
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
const [currentPlatformIndex, setCurrentPlatformIndex] = useState(0);
|
||||
|
||||
const curlCommand = `npx clawhub@latest install clawsec-suite`;
|
||||
|
||||
@@ -20,6 +23,27 @@ export const Home: React.FC = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Rotate platform names every 4-6 seconds
|
||||
useEffect(() => {
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const scheduleNextRotation = () => {
|
||||
const delay = 4000 + Math.floor(Math.random() * 2001);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setCurrentPlatformIndex((prev) => (prev + 1) % PLATFORM_NAMES.length);
|
||||
scheduleNextRotation();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
scheduleNextRotation();
|
||||
|
||||
return () => {
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const humanInstruction = `Please install clawsec-suite from clawhubnpx clawhub@latest install clawsec-suite`;
|
||||
|
||||
const handleCopyCurl = () => {
|
||||
@@ -44,24 +68,20 @@ export const Home: React.FC = () => {
|
||||
{/* Hero Section */}
|
||||
<section className="text-center space-y-6 max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<h2 className="text-3xl md:text-4xl tracking-tight text-white">
|
||||
Secure your <span className="text-clawd-accent">OpenClaw</span> agents
|
||||
</h2>
|
||||
<p className="text-lg md:text-xl text-gray-400 leading-relaxed">
|
||||
A complete security skill suite for OpenClaw's family of agents. Protect your{' '}
|
||||
Secure your{' '}
|
||||
<code
|
||||
key={currentFileIndex}
|
||||
className="px-2 py-1 rounded text-clawd-accent inline-block align-baseline relative text-base"
|
||||
key={currentPlatformIndex}
|
||||
className="px-2 py-1 rounded text-clawd-accent inline-block align-baseline relative"
|
||||
style={{
|
||||
width: '165px',
|
||||
minWidth: '9ch',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'baseline',
|
||||
backgroundColor: 'rgb(30 27 75 / 1)',
|
||||
animation: 'bgFade 0.4s ease-out 1.2s 1 forwards'
|
||||
}}
|
||||
>
|
||||
{FILE_NAMES[currentFileIndex].split('').map((char, index) => (
|
||||
{PLATFORM_NAMES[currentPlatformIndex].split('').map((char, index) => (
|
||||
<span
|
||||
key={`${currentFileIndex}-${index}`}
|
||||
key={`platform-${currentPlatformIndex}-${index}`}
|
||||
className="inline-block"
|
||||
style={{
|
||||
animation: `flipChar 0.3s ease-in-out ${index * 0.05}s 1 forwards`,
|
||||
@@ -73,6 +93,47 @@ export const Home: React.FC = () => {
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</code>{' '}
|
||||
agents
|
||||
</h2>
|
||||
<p className="text-lg md:text-xl text-gray-400 leading-relaxed">
|
||||
A complete security skill suite for OpenClaw and NanoClaw agents. Protect your{' '}
|
||||
<code
|
||||
key={currentFileIndex}
|
||||
className="px-2 py-1 rounded text-clawd-accent inline-block align-baseline relative text-base"
|
||||
style={{
|
||||
width: '188px',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'baseline',
|
||||
backgroundColor: 'rgb(30 27 75 / 1)',
|
||||
animation: 'bgFade 0.4s ease-out 1.2s 1 forwards'
|
||||
}}
|
||||
>
|
||||
<span className="inline-block w-full pr-5">
|
||||
{FILE_NAMES[currentFileIndex].split('').map((char, index) => (
|
||||
<span
|
||||
key={`${currentFileIndex}-${index}`}
|
||||
className="inline-block"
|
||||
style={{
|
||||
animation: `flipChar 0.3s ease-in-out ${index * 0.05}s 1 forwards`,
|
||||
transformStyle: 'preserve-3d',
|
||||
perspective: '400px',
|
||||
opacity: 0
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<Lock
|
||||
size={14}
|
||||
className="text-clawd-accent absolute right-2 top-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
opacity: 0,
|
||||
animation: `lockReveal ${FILE_LOCK_REVEAL_DELAY_MS}ms steps(1, end) 1 forwards`
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</code>
|
||||
{' '}with drift detection, live security recommendations, automated audits, and skill integrity verification. All from one installable suite.
|
||||
</p>
|
||||
@@ -102,6 +163,14 @@ export const Home: React.FC = () => {
|
||||
background-color: rgb(191 107 42 / 0.15);
|
||||
}
|
||||
}
|
||||
@keyframes lockReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@keyframes mascotHover {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12px); }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { ExternalLink, PlayCircle } from 'lucide-react';
|
||||
import { Footer } from '../components/Footer';
|
||||
|
||||
interface DemoVideo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
videoSrc: string;
|
||||
posterSrc: string;
|
||||
videoContainerClassName?: string;
|
||||
}
|
||||
|
||||
const demoVideos: DemoVideo[] = [
|
||||
{
|
||||
id: 'drift-demo',
|
||||
title: 'Drift Detection Demo (soul-guardian)',
|
||||
description:
|
||||
'Shows integrity monitoring in action: tamper detection, alerting, and restoration-oriented behavior for protected files.',
|
||||
videoSrc: '/video/soul-guardian-demo.mp4',
|
||||
posterSrc: '/video/soul-guardian-demo-poster.jpg',
|
||||
},
|
||||
{
|
||||
id: 'install-demo',
|
||||
title: 'Install Demo (clawsec-suite)',
|
||||
description:
|
||||
'Walkthrough of the one-command suite install flow and what gets configured for advisory monitoring and protection.',
|
||||
videoSrc: '/video/install-demo.mp4',
|
||||
posterSrc: '/video/install-demo-poster.jpg',
|
||||
videoContainerClassName: 'md:max-w-[50%]',
|
||||
},
|
||||
];
|
||||
|
||||
export const ProductDemo: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto pt-[52px] space-y-10">
|
||||
<section className="text-center space-y-4">
|
||||
<h1 className="text-3xl md:text-4xl text-white flex items-center justify-center gap-3">
|
||||
<PlayCircle className="text-clawd-accent" />
|
||||
Watch It in Action
|
||||
</h1>
|
||||
<p className="text-gray-400 max-w-3xl mx-auto">
|
||||
Product demos for ClawSec installation and runtime protection behavior. These are the
|
||||
same demo assets referenced in the repository README, presented as playable videos.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-8">
|
||||
{demoVideos.map((demo) => (
|
||||
<article
|
||||
key={demo.id}
|
||||
className="bg-clawd-900 border border-clawd-700 rounded-xl overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pt-6 pb-4 space-y-3">
|
||||
<h2 className="text-xl text-white">{demo.title}</h2>
|
||||
<p className="text-gray-400">{demo.description}</p>
|
||||
</div>
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
<div
|
||||
className={`rounded-lg overflow-hidden border border-clawd-700 bg-black ${
|
||||
demo.videoContainerClassName ?? ''
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
className="w-full h-auto"
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={demo.posterSrc}
|
||||
>
|
||||
<source src={demo.videoSrc} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
<a
|
||||
href={demo.videoSrc}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-clawd-accent hover:underline"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
Open video in new tab
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+38
-117
@@ -5,11 +5,12 @@ import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Footer } from '../components/Footer';
|
||||
import type { SkillJson, SkillChecksums } from '../types';
|
||||
import { defaultMarkdownComponents } from '../utils/markdownComponents';
|
||||
import { stripFrontmatter } from '../utils/markdownHelpers.mjs';
|
||||
|
||||
// Strip YAML frontmatter from markdown content
|
||||
const stripFrontmatter = (content: string): string => {
|
||||
const frontmatterRegex = /^---\s*\n[\s\S]*?\n---\s*\n/;
|
||||
return content.replace(frontmatterRegex, '');
|
||||
const isProbablyHtmlDocument = (text: string): boolean => {
|
||||
const start = text.trimStart().slice(0, 200).toLowerCase();
|
||||
return start.startsWith('<!doctype html') || start.startsWith('<html');
|
||||
};
|
||||
|
||||
export const SkillDetail: React.FC = () => {
|
||||
@@ -29,19 +30,44 @@ export const SkillDetail: React.FC = () => {
|
||||
setDoc(null);
|
||||
|
||||
// Fetch skill.json
|
||||
const skillResponse = await fetch(`./skills/${skillId}/skill.json`);
|
||||
const skillResponse = await fetch(`/skills/${skillId}/skill.json`, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (!skillResponse.ok) {
|
||||
throw new Error('Skill not found');
|
||||
}
|
||||
const skill = await skillResponse.json();
|
||||
|
||||
const skillContentType = skillResponse.headers.get('content-type') ?? '';
|
||||
const skillRaw = await skillResponse.text();
|
||||
if (skillContentType.includes('text/html') || isProbablyHtmlDocument(skillRaw)) {
|
||||
throw new Error('Skill not found');
|
||||
}
|
||||
|
||||
let skill: SkillJson;
|
||||
try {
|
||||
skill = JSON.parse(skillRaw) as SkillJson;
|
||||
} catch {
|
||||
throw new Error('Invalid skill metadata');
|
||||
}
|
||||
|
||||
setSkillData(skill);
|
||||
|
||||
// Fetch checksums.json
|
||||
try {
|
||||
const checksumsResponse = await fetch(`./skills/${skillId}/checksums.json`);
|
||||
const checksumsResponse = await fetch(`/skills/${skillId}/checksums.json`, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (checksumsResponse.ok) {
|
||||
const checksumsData = await checksumsResponse.json();
|
||||
setChecksums(checksumsData);
|
||||
const checksumsContentType = checksumsResponse.headers.get('content-type') ?? '';
|
||||
const checksumsRaw = await checksumsResponse.text();
|
||||
if (!checksumsContentType.includes('text/html') && !isProbablyHtmlDocument(checksumsRaw)) {
|
||||
try {
|
||||
const checksumsData = JSON.parse(checksumsRaw) as SkillChecksums;
|
||||
setChecksums(checksumsData);
|
||||
} catch {
|
||||
// Checksums malformed, ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Checksums not available
|
||||
@@ -51,18 +77,8 @@ export const SkillDetail: React.FC = () => {
|
||||
// Note: Dev servers may fall back to serving index.html with 200 for missing files;
|
||||
// guard against accidentally rendering HTML as docs.
|
||||
try {
|
||||
const isProbablyHtmlDocument = (text: string) => {
|
||||
const start = text.trimStart().slice(0, 200).toLowerCase();
|
||||
return start.startsWith('<!doctype html') || start.startsWith('<html');
|
||||
};
|
||||
|
||||
const stripYamlFrontmatter = (text: string) => {
|
||||
const match = text.match(/^---\\s*\\n[\\s\\S]*?\\n---\\s*\\n/);
|
||||
return match ? text.slice(match[0].length) : text;
|
||||
};
|
||||
|
||||
const fetchDocFile = async (filename: string) => {
|
||||
const response = await fetch(`./skills/${skillId}/${filename}`, {
|
||||
const response = await fetch(`/skills/${skillId}/${filename}`, {
|
||||
headers: { Accept: 'text/plain' }
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
@@ -73,7 +89,7 @@ export const SkillDetail: React.FC = () => {
|
||||
if (contentType.includes('text/html') || isProbablyHtmlDocument(rawText)) return null;
|
||||
|
||||
const text =
|
||||
filename === 'SKILL.md' ? stripYamlFrontmatter(rawText).trim() : rawText.trim();
|
||||
filename === 'SKILL.md' ? stripFrontmatter(rawText).trim() : rawText.trim();
|
||||
|
||||
return text.length > 0 ? text : null;
|
||||
};
|
||||
@@ -300,102 +316,7 @@ export const SkillDetail: React.FC = () => {
|
||||
<div className="skill-docs bg-clawd-800/50 border border-clawd-700 rounded-xl p-4 sm:p-6 md:p-8 overflow-x-hidden">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold text-white border-b border-clawd-700 pb-3 mb-6 mt-0">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-bold text-white mt-8 mb-4">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-semibold text-white mt-6 mb-3">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-base font-semibold text-white mt-4 mb-2">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-clawd-accent hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside text-gray-300 space-y-2 mb-4 ml-4">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside text-gray-300 space-y-2 mb-4 ml-4">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-gray-300">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-clawd-accent pl-4 py-2 my-4 bg-clawd-900/50 rounded-r text-gray-400 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="text-orange-300 bg-clawd-900 px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="text-gray-200 text-sm font-mono">{children}</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-clawd-900 border border-clawd-700 rounded-lg p-3 sm:p-4 overflow-x-auto mb-4 text-xs sm:text-sm max-w-full">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-6 -mx-4 sm:mx-0 px-4 sm:px-0">
|
||||
<table className="w-full border-collapse text-xs sm:text-sm min-w-[300px]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-clawd-900 border-b border-clawd-600">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-clawd-700/50">{children}</tr>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="text-left px-4 py-3 text-gray-300 font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-3 text-gray-300">{children}</td>
|
||||
),
|
||||
hr: () => <hr className="border-clawd-700 my-6" />,
|
||||
strong: ({ children }) => (
|
||||
<strong className="text-white font-semibold">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="text-gray-200">{children}</em>
|
||||
),
|
||||
}}
|
||||
components={defaultMarkdownComponents}
|
||||
>
|
||||
{stripFrontmatter(doc.content)}
|
||||
</Markdown>
|
||||
|
||||
+52
-5
@@ -4,6 +4,27 @@ import { SkillCard } from '../components/SkillCard';
|
||||
import { Footer } from '../components/Footer';
|
||||
import type { SkillMetadata, SkillsIndex } from '../types';
|
||||
|
||||
const SKILLS_INDEX_PATH = '/skills/index.json';
|
||||
|
||||
const isProbablyHtmlDocument = (text: string): boolean => {
|
||||
const start = text.trimStart().slice(0, 200).toLowerCase();
|
||||
return start.startsWith('<!doctype html') || start.startsWith('<html');
|
||||
};
|
||||
|
||||
const parseSkillsIndex = (raw: string): SkillsIndex | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SkillsIndex> | null;
|
||||
if (!parsed || !Array.isArray(parsed.skills)) return null;
|
||||
return {
|
||||
version: typeof parsed.version === 'string' ? parsed.version : '1.0.0',
|
||||
updated: typeof parsed.updated === 'string' ? parsed.updated : '',
|
||||
skills: parsed.skills as SkillMetadata[],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const SkillsCatalog: React.FC = () => {
|
||||
const [skills, setSkills] = useState<SkillMetadata[]>([]);
|
||||
const [filteredSkills, setFilteredSkills] = useState<SkillMetadata[]>([]);
|
||||
@@ -15,15 +36,41 @@ export const SkillsCatalog: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const fetchSkills = async () => {
|
||||
try {
|
||||
const response = await fetch('./skills/index.json');
|
||||
const response = await fetch(SKILLS_INDEX_PATH, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
// Missing index file is a valid "empty catalog" state.
|
||||
if (response.status === 404) {
|
||||
setSkills([]);
|
||||
setFilteredSkills([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch skills index');
|
||||
}
|
||||
const data: SkillsIndex = await response.json();
|
||||
setSkills(data.skills || []);
|
||||
setFilteredSkills(data.skills || []);
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const raw = await response.text();
|
||||
|
||||
// Some SPA setups return index.html with 200 for missing JSON files.
|
||||
if (!raw.trim() || contentType.includes('text/html') || isProbablyHtmlDocument(raw)) {
|
||||
setSkills([]);
|
||||
setFilteredSkills([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = parseSkillsIndex(raw);
|
||||
if (!data) {
|
||||
throw new Error('Invalid skills index format');
|
||||
}
|
||||
|
||||
setSkills(data.skills);
|
||||
setFilteredSkills(data.skills);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load skills');
|
||||
console.error('Failed to load skills index:', err);
|
||||
setError('Failed to load skills catalog');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { BookOpenText, ExternalLink, FileText } from 'lucide-react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import Markdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { defaultMarkdownComponents } from '../utils/markdownComponents';
|
||||
import {
|
||||
extractTitleFromMarkdown,
|
||||
fallbackTitleFromPath,
|
||||
stripFrontmatter,
|
||||
} from '../utils/markdownHelpers.mjs';
|
||||
import {
|
||||
isWikiIndexSlug,
|
||||
toWikiLlmsPath,
|
||||
toWikiRoute,
|
||||
} from '../utils/wikiPathHelpers.mjs';
|
||||
|
||||
interface WikiDoc {
|
||||
filePath: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const normalizePath = (path: string): string => {
|
||||
const clean = path.replace(/\\/g, '/');
|
||||
const parts: string[] = [];
|
||||
for (const part of clean.split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
if (parts.length > 0) parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
};
|
||||
|
||||
const dirname = (path: string): string => {
|
||||
const idx = path.lastIndexOf('/');
|
||||
return idx === -1 ? '' : path.slice(0, idx);
|
||||
};
|
||||
|
||||
const resolveFromFile = (currentFilePath: string, targetPath: string): string => {
|
||||
if (!targetPath) return currentFilePath;
|
||||
if (targetPath.startsWith('/')) return normalizePath(targetPath.slice(1));
|
||||
const baseDir = dirname(currentFilePath);
|
||||
const joined = baseDir ? `${baseDir}/${targetPath}` : targetPath;
|
||||
return normalizePath(joined);
|
||||
};
|
||||
|
||||
const splitHash = (href: string): { path: string; hash: string } => {
|
||||
const idx = href.indexOf('#');
|
||||
if (idx === -1) return { path: href, hash: '' };
|
||||
return { path: href.slice(0, idx), hash: href.slice(idx) };
|
||||
};
|
||||
|
||||
const toWikiRelativePath = (globPath: string): string =>
|
||||
globPath.replace(/^\.\.\/wiki\//, '').replace(/\\/g, '/');
|
||||
|
||||
const isExternalHref = (href: string): boolean =>
|
||||
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href) || href.startsWith('//');
|
||||
|
||||
const ALLOWED_LINK_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
||||
const ALLOWED_IMAGE_SCHEMES = new Set(['http:', 'https:']);
|
||||
|
||||
const sanitizeHref = (href: string): string | null => {
|
||||
const trimmed = href.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('//')) return null;
|
||||
|
||||
const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/);
|
||||
if (!schemeMatch) return trimmed;
|
||||
|
||||
return ALLOWED_LINK_SCHEMES.has(schemeMatch[1].toLowerCase()) ? trimmed : null;
|
||||
};
|
||||
|
||||
const sanitizeImageSrc = (src: string): string | null => {
|
||||
const trimmed = src.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('//')) return null;
|
||||
|
||||
const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/);
|
||||
if (!schemeMatch) return trimmed;
|
||||
|
||||
return ALLOWED_IMAGE_SCHEMES.has(schemeMatch[1].toLowerCase()) ? trimmed : null;
|
||||
};
|
||||
|
||||
const markdownModules = import.meta.glob('../wiki/**/*.md', {
|
||||
eager: true,
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
}) as Record<string, string>;
|
||||
|
||||
const assetModules = import.meta.glob('../wiki/**/*.{png,jpg,jpeg,gif,svg,webp,avif}', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
}) as Record<string, string>;
|
||||
|
||||
const wikiDocs: WikiDoc[] = Object.entries(markdownModules)
|
||||
.map(([globPath, content]) => {
|
||||
const filePath = toWikiRelativePath(globPath);
|
||||
return {
|
||||
filePath,
|
||||
slug: filePath.replace(/\.md$/i, ''),
|
||||
title: extractTitleFromMarkdown(content, filePath),
|
||||
content: stripFrontmatter(content).trim(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aIndex = a.slug.toLowerCase() === 'index';
|
||||
const bIndex = b.slug.toLowerCase() === 'index';
|
||||
if (aIndex && !bIndex) return -1;
|
||||
if (!aIndex && bIndex) return 1;
|
||||
|
||||
const aModule = a.filePath.startsWith('modules/');
|
||||
const bModule = b.filePath.startsWith('modules/');
|
||||
if (aModule !== bModule) return aModule ? 1 : -1;
|
||||
|
||||
return a.title.localeCompare(b.title, 'en', { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
const wikiDocBySlug = new Map<string, WikiDoc>(
|
||||
wikiDocs.map((doc) => [doc.slug.toLowerCase(), doc]),
|
||||
);
|
||||
|
||||
const wikiDocByFilePath = new Map<string, WikiDoc>(
|
||||
wikiDocs.map((doc) => [doc.filePath.toLowerCase(), doc]),
|
||||
);
|
||||
|
||||
const wikiAssetByPath = new Map<string, string>(
|
||||
Object.entries(assetModules).map(([globPath, assetUrl]) => [
|
||||
toWikiRelativePath(globPath).toLowerCase(),
|
||||
assetUrl,
|
||||
]),
|
||||
);
|
||||
|
||||
const defaultDoc = wikiDocBySlug.get('index') ?? wikiDocs[0] ?? null;
|
||||
|
||||
const toGroupName = (filePath: string): string => {
|
||||
if (!filePath.includes('/')) return 'Core';
|
||||
if (filePath.startsWith('modules/')) return 'Modules';
|
||||
const [firstSegment] = filePath.split('/');
|
||||
return fallbackTitleFromPath(firstSegment);
|
||||
};
|
||||
|
||||
export const WikiBrowser: React.FC = () => {
|
||||
const params = useParams<{ '*': string }>();
|
||||
const wildcard = params['*'] ?? '';
|
||||
const normalizedWildcard = wildcard.replace(/^\/+|\/+$/g, '');
|
||||
let requested = '';
|
||||
let decodeFailed = false;
|
||||
try {
|
||||
requested = decodeURIComponent(normalizedWildcard);
|
||||
} catch (error) {
|
||||
decodeFailed = normalizedWildcard.length > 0;
|
||||
console.warn('Failed to decode wiki route segment', { wildcard, error });
|
||||
requested = '';
|
||||
}
|
||||
const requestedSlug = requested || 'INDEX';
|
||||
|
||||
const selectedDoc = wikiDocBySlug.get(requestedSlug.toLowerCase()) ?? defaultDoc;
|
||||
const notFound =
|
||||
(decodeFailed && normalizedWildcard.length > 0) ||
|
||||
(requested.length > 0 && !wikiDocBySlug.has(requestedSlug.toLowerCase()));
|
||||
|
||||
const groupedDocs = useMemo(() => {
|
||||
const map = new Map<string, WikiDoc[]>();
|
||||
for (const doc of wikiDocs) {
|
||||
const group = toGroupName(doc.filePath);
|
||||
const existing = map.get(group) ?? [];
|
||||
existing.push(doc);
|
||||
map.set(group, existing);
|
||||
}
|
||||
|
||||
const preferredOrder = ['Core', 'Modules'];
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => {
|
||||
const idxA = preferredOrder.indexOf(a);
|
||||
const idxB = preferredOrder.indexOf(b);
|
||||
if (idxA !== -1 || idxB !== -1) {
|
||||
if (idxA === -1) return 1;
|
||||
if (idxB === -1) return -1;
|
||||
return idxA - idxB;
|
||||
}
|
||||
return a.localeCompare(b, 'en', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([name, docs]) => ({
|
||||
name,
|
||||
docs: docs.sort((a, b) =>
|
||||
a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }),
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
if (!selectedDoc) {
|
||||
return (
|
||||
<div className="pt-[52px] py-20 text-center space-y-4">
|
||||
<BookOpenText className="w-12 h-12 text-gray-500 mx-auto" />
|
||||
<h1 className="text-2xl text-white">Wiki unavailable</h1>
|
||||
<p className="text-gray-400">No markdown files were found in the wiki source.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeSlug = selectedDoc.slug.toLowerCase();
|
||||
const pageLlmsPath = toWikiLlmsPath(activeSlug);
|
||||
const showWikiLlmsIndexLink = !isWikiIndexSlug(activeSlug);
|
||||
|
||||
const resolveWikiRouteFromHref = (href: string): string | null => {
|
||||
if (!href || isExternalHref(href) || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
||||
return null;
|
||||
}
|
||||
const { path, hash } = splitHash(href);
|
||||
if (!path || !path.toLowerCase().endsWith('.md')) return null;
|
||||
|
||||
const resolvedFilePath = resolveFromFile(selectedDoc.filePath, path).toLowerCase();
|
||||
const targetDoc = wikiDocByFilePath.get(resolvedFilePath);
|
||||
if (!targetDoc) return null;
|
||||
return `${toWikiRoute(targetDoc.slug)}${hash}`;
|
||||
};
|
||||
|
||||
const resolveAssetUrl = (srcOrHref: string): string | null => {
|
||||
if (!srcOrHref || isExternalHref(srcOrHref) || srcOrHref.startsWith('/')) return null;
|
||||
const { path } = splitHash(srcOrHref);
|
||||
if (!path) return null;
|
||||
const resolvedAssetPath = resolveFromFile(selectedDoc.filePath, path).toLowerCase();
|
||||
return wikiAssetByPath.get(resolvedAssetPath) ?? null;
|
||||
};
|
||||
|
||||
const wikiMarkdownComponents: Components = {
|
||||
...defaultMarkdownComponents,
|
||||
a: ({ href, children }) => {
|
||||
if (!href) return <span className="text-gray-300">{children}</span>;
|
||||
|
||||
const wikiRoute = resolveWikiRouteFromHref(href);
|
||||
if (wikiRoute) {
|
||||
return (
|
||||
<Link to={wikiRoute} className="text-clawd-accent hover:underline">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const assetHref = resolveAssetUrl(href);
|
||||
const finalHref = assetHref ?? href;
|
||||
const safeHref = sanitizeHref(finalHref);
|
||||
if (!safeHref) {
|
||||
return <span className="text-gray-300">{children}</span>;
|
||||
}
|
||||
const external = isExternalHref(safeHref);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className="text-clawd-accent hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) => {
|
||||
const resolvedSrc = src ? resolveAssetUrl(src) : null;
|
||||
const finalSrc = resolvedSrc ?? (src ? sanitizeImageSrc(src) : null);
|
||||
if (!finalSrc) {
|
||||
return <span className="text-gray-500 text-sm">[image blocked]</span>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={finalSrc}
|
||||
alt={alt ?? ''}
|
||||
className="max-w-full h-auto rounded-lg border border-clawd-700 bg-clawd-900/40 p-2 my-4"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-[52px] space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h1 className="text-3xl md:text-4xl text-white flex items-center gap-3">
|
||||
<BookOpenText className="text-clawd-accent" />
|
||||
Wiki
|
||||
</h1>
|
||||
<p className="text-gray-400 max-w-3xl">
|
||||
Full repository wiki rendered from markdown in <code className="text-gray-300">wiki/</code>.
|
||||
This is the same source synced to GitHub Wiki.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href={pageLlmsPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-clawd-700 hover:bg-clawd-600 text-white text-sm transition-colors"
|
||||
>
|
||||
<FileText size={15} />
|
||||
Page llms.txt
|
||||
</a>
|
||||
{showWikiLlmsIndexLink && (
|
||||
<a
|
||||
href="/wiki/llms.txt"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-clawd-800 border border-clawd-700 hover:border-clawd-accent text-white text-sm transition-colors"
|
||||
>
|
||||
<FileText size={15} />
|
||||
Wiki llms.txt Index
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border border-clawd-700 hover:border-clawd-accent text-gray-200 text-sm transition-colors"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
GitHub Wiki
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid lg:grid-cols-[280px_minmax(0,1fr)] gap-6 items-start">
|
||||
<aside className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-4 lg:sticky lg:top-20 max-h-[calc(100vh-7rem)] overflow-auto">
|
||||
<div className="space-y-5">
|
||||
{groupedDocs.map((group) => (
|
||||
<section key={group.name} className="space-y-2">
|
||||
<h2 className="text-xs uppercase tracking-wide text-gray-400">{group.name}</h2>
|
||||
<div className="space-y-1">
|
||||
{group.docs.map((doc) => {
|
||||
const isActive = activeSlug === doc.slug.toLowerCase();
|
||||
return (
|
||||
<Link
|
||||
key={doc.filePath}
|
||||
to={toWikiRoute(doc.slug)}
|
||||
className={`block px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white border border-white/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{doc.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-4 sm:p-6 md:p-8 overflow-x-hidden">
|
||||
{notFound && (
|
||||
<div className="mb-6 p-3 rounded-md border border-orange-800 bg-orange-900/20 text-orange-200 text-sm">
|
||||
Wiki page not found for <code>{requested}</code>. Showing <strong>{selectedDoc.title}</strong> instead.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={wikiMarkdownComponents}
|
||||
>
|
||||
{selectedDoc.content}
|
||||
</Markdown>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 970 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/bin/bash
|
||||
# backfill-exploitability.sh
|
||||
# Adds exploitability scoring to existing advisories in feed.json that don't have it yet.
|
||||
# Historical maintenance utility: normal advisory generation should use
|
||||
# poll-nvd workflow (init/reset when rebuilding) or populate-local-feed.sh.
|
||||
#
|
||||
# Usage: ./scripts/backfill-exploitability.sh [--dry-run] [--feed PATH]
|
||||
# --dry-run Show what would be updated without making changes
|
||||
# --feed PATH Use specified feed file (default: advisories/feed.json)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
# shellcheck source=./feed-utils.sh
|
||||
source "$SCRIPT_DIR/feed-utils.sh"
|
||||
|
||||
# Configuration
|
||||
init_feed_paths "$PROJECT_ROOT"
|
||||
ANALYZER="$PROJECT_ROOT/utils/analyze_exploitability.py"
|
||||
SIGNING_PRIVATE_KEY="${CLAWSEC_FEED_SIGNING_PRIVATE_KEY_PATH:-${CLAWSEC_SIGNING_PRIVATE_KEY_PATH:-$PROJECT_ROOT/clawsec-signing-private.pem}}"
|
||||
SIGNING_PUBLIC_KEY="${CLAWSEC_FEED_SIGNING_PUBLIC_KEY_PATH:-${CLAWSEC_SIGNING_PUBLIC_KEY_PATH:-$PROJECT_ROOT/clawsec-signing-public.pem}}"
|
||||
SIGNING_PASSPHRASE="${CLAWSEC_FEED_SIGNING_PRIVATE_KEY_PASSPHRASE:-${CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE:-}}"
|
||||
|
||||
sign_and_verify_feed_signature() {
|
||||
local feed_file="$1"
|
||||
local signature_file="$2"
|
||||
local tmp_dir
|
||||
local tmp_signature
|
||||
local signature_bin
|
||||
local passin_file
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
tmp_signature="${signature_file}.tmp.$$"
|
||||
signature_bin="$tmp_dir/signature.bin"
|
||||
passin_file="$tmp_dir/passin.txt"
|
||||
|
||||
if [ -n "$SIGNING_PASSPHRASE" ]; then
|
||||
printf '%s' "$SIGNING_PASSPHRASE" > "$passin_file"
|
||||
chmod 600 "$passin_file"
|
||||
|
||||
if ! openssl pkeyutl -sign -rawin -inkey "$SIGNING_PRIVATE_KEY" -passin "file:$passin_file" -in "$feed_file" \
|
||||
| openssl base64 -A > "$tmp_signature"; then
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$tmp_signature"
|
||||
echo "Error: Failed to sign $feed_file" >&2
|
||||
return 1
|
||||
fi
|
||||
elif ! openssl pkeyutl -sign -rawin -inkey "$SIGNING_PRIVATE_KEY" -in "$feed_file" \
|
||||
| openssl base64 -A > "$tmp_signature"; then
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$tmp_signature"
|
||||
echo "Error: Failed to sign $feed_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! openssl base64 -d -A -in "$tmp_signature" -out "$signature_bin"; then
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$tmp_signature"
|
||||
echo "Error: Failed to decode generated signature for $feed_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$SIGNING_PUBLIC_KEY" -sigfile "$signature_bin" -in "$feed_file" >/dev/null; then
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$tmp_signature"
|
||||
echo "Error: Signature verification failed after signing $feed_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
mv "$tmp_signature" "$signature_file"
|
||||
rm -rf "$tmp_dir"
|
||||
echo "✓ Re-signed and verified: $signature_file"
|
||||
}
|
||||
|
||||
# Parse args
|
||||
DRY_RUN=false
|
||||
REQUIRE_SIGNING=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--feed)
|
||||
FEED_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--dry-run] [--feed PATH]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "=== ClawSec Exploitability Backfill ==="
|
||||
echo "Feed path: $FEED_PATH"
|
||||
echo "Dry run: $DRY_RUN"
|
||||
echo ""
|
||||
|
||||
# Verify prerequisites
|
||||
if [ ! -f "$FEED_PATH" ]; then
|
||||
echo "Error: Feed file not found: $FEED_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ANALYZER" ]; then
|
||||
echo "Error: Analyzer script not found: $ANALYZER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Python availability
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "Error: python3 is required but not found in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify analyzer works
|
||||
if ! python3 "$ANALYZER" --help &> /dev/null; then
|
||||
echo "Error: Analyzer script failed to run. Check Python environment."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine whether detached signatures must be regenerated.
|
||||
# Runtime agents that only have public keys should run in dry-run mode.
|
||||
if [ "$DRY_RUN" = "false" ]; then
|
||||
if [ -f "${FEED_PATH}.sig" ]; then
|
||||
REQUIRE_SIGNING=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$REQUIRE_SIGNING" = "true" ]; then
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
echo "Error: openssl is required for detached signature signing/verification"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SIGNING_PRIVATE_KEY" ]; then
|
||||
echo "Error: Signing private key not found: $SIGNING_PRIVATE_KEY"
|
||||
echo "This backfill updates signed feed artifacts. Use --dry-run in public-key-only environments."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SIGNING_PUBLIC_KEY" ]; then
|
||||
echo "Error: Signing public key not found: $SIGNING_PUBLIC_KEY"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create temp directory
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
echo "=== Analyzing Feed ==="
|
||||
|
||||
# Extract advisories without exploitability_score
|
||||
jq '.advisories | map(select(.exploitability_score == null or .exploitability_score == ""))' \
|
||||
"$FEED_PATH" > "$TEMP_DIR/missing_exploitability.json"
|
||||
|
||||
MISSING_COUNT=$(jq 'length' "$TEMP_DIR/missing_exploitability.json")
|
||||
TOTAL_COUNT=$(jq '.advisories | length' "$FEED_PATH")
|
||||
ALREADY_DONE=$((TOTAL_COUNT - MISSING_COUNT))
|
||||
|
||||
echo "Total advisories: $TOTAL_COUNT"
|
||||
echo "Already have exploitability: $ALREADY_DONE"
|
||||
echo "Missing exploitability: $MISSING_COUNT"
|
||||
echo ""
|
||||
|
||||
if [ "$MISSING_COUNT" -eq 0 ]; then
|
||||
echo "✓ All advisories already have exploitability scores!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "=== Dry Run - Would Update These Advisories ==="
|
||||
jq -r '.[] | .id' "$TEMP_DIR/missing_exploitability.json"
|
||||
echo ""
|
||||
echo "Total advisories to update: $MISSING_COUNT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "=== Processing Advisories ==="
|
||||
|
||||
# Process each advisory
|
||||
PROCESSED=0
|
||||
FAILED=0
|
||||
|
||||
# Read original feed to preserve all metadata
|
||||
cp "$FEED_PATH" "$TEMP_DIR/feed_working.json"
|
||||
|
||||
while IFS= read -r advisory; do
|
||||
CVE_ID=$(echo "$advisory" | jq -r '.id')
|
||||
|
||||
echo -n "Processing $CVE_ID... "
|
||||
|
||||
# Prepare input for analyzer
|
||||
ANALYZER_INPUT=$(echo "$advisory" | jq '{
|
||||
cve_id: .id,
|
||||
cvss_score: (.cvss_score // 0.0),
|
||||
type: .type,
|
||||
description: .description,
|
||||
references: (.references // [])
|
||||
}')
|
||||
|
||||
# Run analyzer
|
||||
if ANALYSIS=$(echo "$ANALYZER_INPUT" | python3 "$ANALYZER" --json --check-exploits 2>/dev/null); then
|
||||
# Extract exploitability fields
|
||||
EXPL_SCORE=$(echo "$ANALYSIS" | jq -r '.exploitability_score // "unknown"')
|
||||
EXPL_RATIONALE=$(echo "$ANALYSIS" | jq -r '.exploitability_rationale // "No rationale available"')
|
||||
|
||||
# Update advisory in working feed
|
||||
jq --arg id "$CVE_ID" \
|
||||
--arg score "$EXPL_SCORE" \
|
||||
--arg rationale "$EXPL_RATIONALE" \
|
||||
'(.advisories[] | select(.id == $id)) |= (. + {
|
||||
exploitability_score: $score,
|
||||
exploitability_rationale: $rationale
|
||||
})' "$TEMP_DIR/feed_working.json" > "$TEMP_DIR/feed_updated.json"
|
||||
|
||||
mv "$TEMP_DIR/feed_updated.json" "$TEMP_DIR/feed_working.json"
|
||||
|
||||
echo "✓ $EXPL_SCORE"
|
||||
PROCESSED=$((PROCESSED + 1))
|
||||
else
|
||||
echo "✗ Failed"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done < <(jq -c '.[]' "$TEMP_DIR/missing_exploitability.json")
|
||||
|
||||
# Check if loop executed successfully
|
||||
if [ ! -f "$TEMP_DIR/feed_working.json" ]; then
|
||||
echo "Error: Feed processing failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Processing Complete ==="
|
||||
echo "Processed: $PROCESSED"
|
||||
echo "Failed: $FAILED"
|
||||
echo ""
|
||||
|
||||
# Write updated feed
|
||||
echo "Writing updated feed to: $FEED_PATH"
|
||||
cp "$TEMP_DIR/feed_working.json" "$FEED_PATH"
|
||||
|
||||
# Update feed version and timestamp
|
||||
CURRENT_VERSION=$(jq -r '.version' "$FEED_PATH")
|
||||
UPDATED_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
jq --arg ts "$UPDATED_TS" '.updated = $ts' "$FEED_PATH" > "$TEMP_DIR/feed_final.json"
|
||||
mv "$TEMP_DIR/feed_final.json" "$FEED_PATH"
|
||||
|
||||
echo "✓ Updated feed version: $CURRENT_VERSION"
|
||||
echo "✓ Updated timestamp: $UPDATED_TS"
|
||||
echo ""
|
||||
|
||||
if [ "$REQUIRE_SIGNING" = "true" ]; then
|
||||
echo ""
|
||||
echo "=== Re-signing Advisory Feed ==="
|
||||
|
||||
if [ -f "${FEED_PATH}.sig" ]; then
|
||||
if ! sign_and_verify_feed_signature "$FEED_PATH" "${FEED_PATH}.sig"; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo "✓ Backfill complete!"
|
||||
echo "✓ $PROCESSED advisories updated with exploitability scores"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "⚠ $FAILED advisories failed analysis (kept original data)"
|
||||
fi
|
||||
|
||||
# Verify final state
|
||||
FINAL_MISSING=$(jq '.advisories | map(select(.exploitability_score == null or .exploitability_score == "")) | length' "$FEED_PATH")
|
||||
echo "✓ Advisories still missing exploitability: $FINAL_MISSING"
|
||||
Executable
+263
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ci/enrich_exploitability.sh --mode single|batch --input <path> --output <path> [--cvss-vectors <path>] [--analyzer <path>]
|
||||
|
||||
Options:
|
||||
--mode Processing mode: single advisory object or batch advisory array
|
||||
--input Input JSON path
|
||||
--output Output JSON path
|
||||
--cvss-vectors Optional JSON object mapping advisory id -> CVSS vector
|
||||
--analyzer Optional analyzer path (default: utils/analyze_exploitability.py)
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
MODE=""
|
||||
INPUT_PATH=""
|
||||
OUTPUT_PATH=""
|
||||
CVSS_VECTORS_PATH=""
|
||||
ANALYZER_PATH="utils/analyze_exploitability.py"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--input)
|
||||
INPUT_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
OUTPUT_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--cvss-vectors)
|
||||
CVSS_VECTORS_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--analyzer)
|
||||
ANALYZER_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$MODE" != "single" && "$MODE" != "batch" ]]; then
|
||||
echo "ERROR: --mode must be one of: single, batch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$INPUT_PATH" || -z "$OUTPUT_PATH" ]]; then
|
||||
echo "ERROR: --input and --output are required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$INPUT_PATH" ]]; then
|
||||
echo "ERROR: input file not found: $INPUT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ANALYZER_PATH" ]]; then
|
||||
echo "ERROR: analyzer file not found: $ANALYZER_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$CVSS_VECTORS_PATH" && ! -f "$CVSS_VECTORS_PATH" ]]; then
|
||||
echo "ERROR: --cvss-vectors file not found: $CVSS_VECTORS_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "ERROR: jq is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python3"
|
||||
else
|
||||
echo "ERROR: python or python3 is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
resolve_cvss_vector() {
|
||||
local advisory_json="$1"
|
||||
local advisory_id
|
||||
advisory_id="$(echo "$advisory_json" | jq -r '.id // ""')"
|
||||
|
||||
if [[ -n "$CVSS_VECTORS_PATH" ]]; then
|
||||
jq -r --arg id "$advisory_id" '.[$id] // ""' "$CVSS_VECTORS_PATH"
|
||||
else
|
||||
echo "$advisory_json" | jq -r '.cvss_vector // ""'
|
||||
fi
|
||||
}
|
||||
|
||||
severity_to_cvss() {
|
||||
case "$1" in
|
||||
critical) echo "9.5" ;;
|
||||
high) echo "7.5" ;;
|
||||
medium) echo "5.5" ;;
|
||||
low) echo "3.0" ;;
|
||||
*) echo "5.0" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_analysis_input() {
|
||||
local advisory_json="$1"
|
||||
local mode="$2"
|
||||
local cve_id cvss_score cvss_vector vuln_type description references severity
|
||||
|
||||
cve_id="$(echo "$advisory_json" | jq -r '.id // ""')"
|
||||
vuln_type="$(echo "$advisory_json" | jq -r '.type // ""')"
|
||||
description="$(echo "$advisory_json" | jq -r '.description // ""')"
|
||||
references="$(echo "$advisory_json" | jq -c '.references // []')"
|
||||
cvss_vector="$(resolve_cvss_vector "$advisory_json")"
|
||||
|
||||
if [[ "$mode" == "single" ]]; then
|
||||
severity="$(echo "$advisory_json" | jq -r '.severity // "medium"')"
|
||||
cvss_score="$(severity_to_cvss "$severity")"
|
||||
else
|
||||
cvss_score="$(echo "$advisory_json" | jq -r '.cvss_score // 0')"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg cve_id "$cve_id" \
|
||||
--argjson cvss_score "$cvss_score" \
|
||||
--arg cvss_vector "$cvss_vector" \
|
||||
--arg type "$vuln_type" \
|
||||
--arg description "$description" \
|
||||
--argjson references "$references" \
|
||||
'{
|
||||
cve_id: $cve_id,
|
||||
cvss_score: $cvss_score,
|
||||
cvss_vector: $cvss_vector,
|
||||
type: $type,
|
||||
description: $description,
|
||||
references: $references
|
||||
}'
|
||||
}
|
||||
|
||||
run_analysis() {
|
||||
local advisory_json="$1"
|
||||
local mode="$2"
|
||||
local output_file="$3"
|
||||
local advisory_id analysis_input analysis
|
||||
|
||||
advisory_id="$(echo "$advisory_json" | jq -r '.id // "unknown"')"
|
||||
analysis_input="$(build_analysis_input "$advisory_json" "$mode")"
|
||||
|
||||
if analysis="$(echo "$analysis_input" | "$PYTHON_BIN" "$ANALYZER_PATH" --json --check-exploits 2>/dev/null)"; then
|
||||
echo "$analysis" > "$output_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "::warning::Failed to analyze exploitability for $advisory_id, continuing without enrichment"
|
||||
return 1
|
||||
}
|
||||
|
||||
enrich_single() {
|
||||
if ! jq -e 'type == "object"' "$INPUT_PATH" >/dev/null; then
|
||||
echo "ERROR: single mode expects JSON object at $INPUT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local advisory analysis_file output_tmp
|
||||
advisory="$(cat "$INPUT_PATH")"
|
||||
analysis_file="$tmpdir/analysis_single.json"
|
||||
output_tmp="$tmpdir/output_single.json"
|
||||
|
||||
if run_analysis "$advisory" "single" "$analysis_file"; then
|
||||
jq --slurpfile analysis "$analysis_file" '
|
||||
. + {
|
||||
exploitability_score: $analysis[0].exploitability_score,
|
||||
exploitability_rationale: $analysis[0].exploitability_rationale,
|
||||
attack_vector_analysis: $analysis[0].attack_vector_analysis,
|
||||
exploit_detection: $analysis[0].exploit_detection
|
||||
}
|
||||
' "$INPUT_PATH" > "$output_tmp"
|
||||
else
|
||||
cp "$INPUT_PATH" "$output_tmp"
|
||||
fi
|
||||
|
||||
mv "$output_tmp" "$OUTPUT_PATH"
|
||||
echo "Exploitability enrichment complete (single): $OUTPUT_PATH"
|
||||
}
|
||||
|
||||
enrich_batch() {
|
||||
if ! jq -e 'type == "array"' "$INPUT_PATH" >/dev/null; then
|
||||
echo "ERROR: batch mode expects JSON array at $INPUT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local analyzed_count failed_count index advisory analysis_file output_tmp analyses_json
|
||||
analyzed_count=0
|
||||
failed_count=0
|
||||
index=0
|
||||
analyses_json="$tmpdir/analyses.json"
|
||||
output_tmp="$tmpdir/output_batch.json"
|
||||
|
||||
while IFS= read -r advisory; do
|
||||
analysis_file="$tmpdir/analysis_${index}.json"
|
||||
if run_analysis "$advisory" "batch" "$analysis_file"; then
|
||||
analyzed_count=$((analyzed_count + 1))
|
||||
else
|
||||
failed_count=$((failed_count + 1))
|
||||
rm -f "$analysis_file"
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done < <(jq -c '.[]' "$INPUT_PATH")
|
||||
|
||||
if ls "$tmpdir"/analysis_*.json >/dev/null 2>&1; then
|
||||
jq -s '.' "$tmpdir"/analysis_*.json > "$analyses_json"
|
||||
else
|
||||
echo '[]' > "$analyses_json"
|
||||
fi
|
||||
|
||||
jq --slurpfile analyses "$analyses_json" '
|
||||
map(
|
||||
. as $advisory |
|
||||
($analyses[0] | map(select(.cve_id == $advisory.id)) | first) as $analysis |
|
||||
if $analysis then
|
||||
$advisory + {
|
||||
exploitability_score: $analysis.exploitability_score,
|
||||
exploitability_rationale: $analysis.exploitability_rationale,
|
||||
attack_vector_analysis: $analysis.attack_vector_analysis,
|
||||
exploit_detection: $analysis.exploit_detection
|
||||
}
|
||||
else
|
||||
$advisory
|
||||
end
|
||||
)
|
||||
' "$INPUT_PATH" > "$output_tmp"
|
||||
|
||||
mv "$output_tmp" "$OUTPUT_PATH"
|
||||
echo "Exploitability enrichment complete (batch): $OUTPUT_PATH"
|
||||
echo "Analyzed: $analyzed_count, failed: $failed_count"
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "single" ]]; then
|
||||
enrich_single
|
||||
else
|
||||
enrich_batch
|
||||
fi
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# feed-utils.sh
|
||||
# Shared advisory feed path and sync helpers for local/maintenance scripts.
|
||||
|
||||
init_feed_paths() {
|
||||
local project_root="$1"
|
||||
|
||||
: "${FEED_PATH:=$project_root/advisories/feed.json}"
|
||||
: "${SKILL_FEED_PATH:=$project_root/skills/clawsec-feed/advisories/feed.json}"
|
||||
: "${PUBLIC_FEED_PATH:=$project_root/public/advisories/feed.json}"
|
||||
}
|
||||
|
||||
sync_feed_to_mirrors() {
|
||||
local source_feed="$1"
|
||||
local mode="${2:-create}"
|
||||
|
||||
local target
|
||||
for target in "$SKILL_FEED_PATH" "$PUBLIC_FEED_PATH"; do
|
||||
case "$mode" in
|
||||
create)
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp "$source_feed" "$target"
|
||||
echo "✓ Updated: $target"
|
||||
;;
|
||||
existing-only)
|
||||
if [ -f "$target" ]; then
|
||||
cp "$source_feed" "$target"
|
||||
echo "✓ Updated: $target"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Error: unsupported mirror sync mode: $mode" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
extractTitleFromMarkdown,
|
||||
stripFrontmatter,
|
||||
} from '../utils/markdownHelpers.mjs';
|
||||
import {
|
||||
isWikiIndexSlug,
|
||||
toWikiLlmsPath,
|
||||
toWikiRoute,
|
||||
} from '../utils/wikiPathHelpers.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const WIKI_ROOT = path.join(REPO_ROOT, 'wiki');
|
||||
const PUBLIC_WIKI_ROOT = path.join(REPO_ROOT, 'public', 'wiki');
|
||||
const LLM_INDEX_FILE = path.join(PUBLIC_WIKI_ROOT, 'llms.txt');
|
||||
|
||||
const WEBSITE_BASE = 'https://clawsec.prompt.security';
|
||||
const REPO_BASE = 'https://github.com/prompt-security/clawsec';
|
||||
const RAW_BASE = 'https://raw.githubusercontent.com/prompt-security/clawsec/main';
|
||||
|
||||
const toPosix = (inputPath) => inputPath.split(path.sep).join('/');
|
||||
const toLlmsPageUrl = (slug) => `${WEBSITE_BASE}${toWikiLlmsPath(slug)}`;
|
||||
|
||||
const walkMarkdownFiles = async (dir) => {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const nested = await walkMarkdownFiles(fullPath);
|
||||
files.push(...nested);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const sortDocs = (a, b) => {
|
||||
if (a.slug === 'index' && b.slug !== 'index') return -1;
|
||||
if (a.slug !== 'index' && b.slug === 'index') return 1;
|
||||
return a.slug.localeCompare(b.slug, 'en', { sensitivity: 'base' });
|
||||
};
|
||||
|
||||
const buildPageBody = (doc) => {
|
||||
const pageRoute = toWikiRoute(doc.slug);
|
||||
const pageUrl = `${WEBSITE_BASE}/#${pageRoute}`;
|
||||
const sourceUrl = `${RAW_BASE}/wiki/${doc.relativePath}`;
|
||||
const llmsUrl = toLlmsPageUrl(doc.slug);
|
||||
|
||||
return [
|
||||
`# ClawSec Wiki · ${doc.title}`,
|
||||
'',
|
||||
'LLM-ready export for a single wiki page.',
|
||||
'',
|
||||
'## Canonical',
|
||||
`- Wiki page: ${pageUrl}`,
|
||||
`- LLM export: ${llmsUrl}`,
|
||||
`- Source markdown: ${sourceUrl}`,
|
||||
'',
|
||||
'## Markdown',
|
||||
'',
|
||||
doc.content.trim(),
|
||||
'',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const buildFallbackIndexBody = (docs) => {
|
||||
const lines = [
|
||||
'# ClawSec Wiki llms.txt',
|
||||
'',
|
||||
'LLM-readable index for wiki pages.',
|
||||
'',
|
||||
`Website wiki root: ${WEBSITE_BASE}/#/wiki`,
|
||||
`GitHub wiki mirror: ${REPO_BASE}/wiki`,
|
||||
`Canonical source of truth: ${REPO_BASE}/tree/main/wiki`,
|
||||
'',
|
||||
'## Generated Page Exports',
|
||||
];
|
||||
|
||||
for (const doc of docs) {
|
||||
const pageRoute = toWikiRoute(doc.slug);
|
||||
const pageUrl = `${WEBSITE_BASE}/#${pageRoute}`;
|
||||
const llmsUrl = toLlmsPageUrl(doc.slug);
|
||||
lines.push(`- ${doc.title}: ${llmsUrl} (page: ${pageUrl})`);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
try {
|
||||
const wikiStat = await fs.stat(WIKI_ROOT).catch(() => null);
|
||||
if (!wikiStat || !wikiStat.isDirectory()) {
|
||||
throw new Error('wiki/ directory not found.');
|
||||
}
|
||||
|
||||
const markdownFiles = await walkMarkdownFiles(WIKI_ROOT);
|
||||
const docs = [];
|
||||
|
||||
for (const fullPath of markdownFiles) {
|
||||
const relativePath = toPosix(path.relative(WIKI_ROOT, fullPath));
|
||||
const slug = relativePath.replace(/\.md$/i, '').toLowerCase();
|
||||
const rawContent = await fs.readFile(fullPath, 'utf8');
|
||||
const content = stripFrontmatter(rawContent);
|
||||
const title = extractTitleFromMarkdown(rawContent, relativePath);
|
||||
docs.push({ relativePath, slug, title, content });
|
||||
}
|
||||
|
||||
docs.sort(sortDocs);
|
||||
const pageDocs = docs.filter((doc) => !isWikiIndexSlug(doc.slug));
|
||||
const indexDoc = docs.find((doc) => isWikiIndexSlug(doc.slug));
|
||||
|
||||
// `public/wiki/` is fully generated; wipe stale output before regenerating.
|
||||
await fs.rm(PUBLIC_WIKI_ROOT, { recursive: true, force: true });
|
||||
await fs.mkdir(PUBLIC_WIKI_ROOT, { recursive: true });
|
||||
|
||||
for (const doc of pageDocs) {
|
||||
const outputFile = path.join(PUBLIC_WIKI_ROOT, doc.slug, 'llms.txt');
|
||||
await fs.mkdir(path.dirname(outputFile), { recursive: true });
|
||||
await fs.writeFile(outputFile, buildPageBody(doc), 'utf8');
|
||||
}
|
||||
|
||||
const indexBody = indexDoc ? buildPageBody(indexDoc) : buildFallbackIndexBody(pageDocs);
|
||||
await fs.writeFile(LLM_INDEX_FILE, indexBody, 'utf8');
|
||||
|
||||
// Keep logs short for CI readability.
|
||||
console.log(`Generated ${pageDocs.length} page llms.txt exports and /wiki/llms.txt`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Failed to generate wiki llms exports: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -11,13 +11,14 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
# shellcheck source=./feed-utils.sh
|
||||
source "$SCRIPT_DIR/feed-utils.sh"
|
||||
|
||||
# Configuration - same as pipeline
|
||||
FEED_PATH="$PROJECT_ROOT/advisories/feed.json"
|
||||
SKILL_FEED_PATH="$PROJECT_ROOT/skills/clawsec-feed/advisories/feed.json"
|
||||
PUBLIC_FEED_PATH="$PROJECT_ROOT/public/advisories/feed.json"
|
||||
KEYWORDS="OpenClaw clawdbot Moltbot"
|
||||
GITHUB_REF_PATTERN="github.com/openclaw/openclaw"
|
||||
init_feed_paths "$PROJECT_ROOT"
|
||||
KEYWORDS="OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys"
|
||||
GITHUB_REF_PATTERN="github.com/openclaw/openclaw github.com/qwibitai/NanoClaw"
|
||||
ENRICH_SCRIPT="$PROJECT_ROOT/scripts/ci/enrich_exploitability.sh"
|
||||
|
||||
# Parse args
|
||||
DAYS_BACK=120
|
||||
@@ -46,6 +47,12 @@ echo "Days back: $DAYS_BACK"
|
||||
echo "Force mode: $FORCE"
|
||||
echo ""
|
||||
|
||||
# Verify enrichment helper exists (it validates Python/analyzer prerequisites internally).
|
||||
if [ ! -x "$ENRICH_SCRIPT" ]; then
|
||||
echo "Error: Exploitability enrichment helper not found or not executable: $ENRICH_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create temp directory
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
@@ -62,7 +69,7 @@ fi
|
||||
if [ -z "${START_DATE:-}" ]; then
|
||||
# macOS vs Linux date compatibility
|
||||
if date -v-1d > /dev/null 2>&1; then
|
||||
START_DATE=$(date -u -v-${DAYS_BACK}d +%Y-%m-%dT%H:%M:%S.000Z)
|
||||
START_DATE=$(date -u -v-"${DAYS_BACK}"d +%Y-%m-%dT%H:%M:%S.000Z)
|
||||
else
|
||||
START_DATE=$(date -u -d "${DAYS_BACK} days ago" +%Y-%m-%dT%H:%M:%S.000Z)
|
||||
fi
|
||||
@@ -74,8 +81,8 @@ echo "End date: $END_DATE"
|
||||
echo ""
|
||||
|
||||
# URL encode dates
|
||||
START_ENC=$(echo "$START_DATE" | sed 's/:/%3A/g')
|
||||
END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
|
||||
START_ENC=${START_DATE//:/%3A}
|
||||
END_ENC=${END_DATE//:/%3A}
|
||||
|
||||
echo "=== Fetching CVEs from NVD ==="
|
||||
|
||||
@@ -128,7 +135,7 @@ TOTAL=$(jq 'length' "$TEMP_DIR/unique_cves.json")
|
||||
echo "Total unique CVEs from NVD: $TOTAL"
|
||||
|
||||
# Post-filter: keep only CVEs matching our criteria
|
||||
KEYWORDS_PATTERN="OpenClaw|clawdbot|Moltbot|openclaw"
|
||||
KEYWORDS_PATTERN="OpenClaw|clawdbot|Moltbot|openclaw|NanoClaw|nanoclaw|WhatsApp-bot|baileys"
|
||||
|
||||
jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_REF_PATTERN" '
|
||||
[.[] | select(
|
||||
@@ -236,8 +243,38 @@ jq --argjson existing "$EXISTING_JSON" '
|
||||
else (cwe_name_map($id) // ("unknown_cwe_" + $id))
|
||||
end
|
||||
);
|
||||
|
||||
def cpe_criteria:
|
||||
(
|
||||
[.cve.configurations[]? | .. | objects | .criteria? | strings | select(startswith("cpe:2.3:"))]
|
||||
| unique
|
||||
);
|
||||
|
||||
def inferred_targets:
|
||||
(
|
||||
(
|
||||
[
|
||||
(.cve.descriptions[]? | select(.lang == "en") | .value),
|
||||
(.cve.references[]?.url // empty)
|
||||
]
|
||||
| map(strings | ascii_downcase)
|
||||
| join(" ")
|
||||
) as $blob
|
||||
| (
|
||||
(if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end)
|
||||
+ (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end)
|
||||
)
|
||||
);
|
||||
|
||||
def normalized_affected:
|
||||
(
|
||||
(cpe_criteria + inferred_targets)
|
||||
| unique
|
||||
| .[0:5]
|
||||
| if length == 0 then ["openclaw@*", "nanoclaw@*"] else . end
|
||||
);
|
||||
|
||||
[.[] |
|
||||
[.[] |
|
||||
select(.cve.id as $id | $existing | index($id) | not) |
|
||||
{
|
||||
id: .cve.id,
|
||||
@@ -246,12 +283,14 @@ jq --argjson existing "$EXISTING_JSON" '
|
||||
nvd_category_id: nvd_category_raw,
|
||||
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
|
||||
description: (.cve.descriptions[] | select(.lang == "en") | .value),
|
||||
affected: [.cve.configurations[]?.nodes[]?.cpeMatch[]?.criteria // empty] | unique | .[0:5],
|
||||
affected: normalized_affected,
|
||||
action: "Review and update affected components. See NVD for remediation details.",
|
||||
published: .cve.published,
|
||||
references: [.cve.references[]?.url // empty] | unique | .[0:3],
|
||||
cvss_score: get_cvss_score,
|
||||
nvd_url: ("https://nvd.nist.gov/vuln/detail/" + .cve.id)
|
||||
nvd_url: ("https://nvd.nist.gov/vuln/detail/" + .cve.id),
|
||||
exploitability_score: null,
|
||||
exploitability_rationale: null
|
||||
}
|
||||
]
|
||||
' "$TEMP_DIR/filtered_cves.json" > "$TEMP_DIR/new_advisories.json"
|
||||
@@ -266,6 +305,28 @@ if [ "$NEW_COUNT" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Analyzing Exploitability ==="
|
||||
|
||||
# Build CVSS vector lookup for enriched analysis inputs.
|
||||
jq '
|
||||
[.[] | {
|
||||
id: .cve.id,
|
||||
cvss_vector: (
|
||||
.cve.metrics.cvssMetricV31[0]?.cvssData.vectorString //
|
||||
.cve.metrics.cvssMetricV30[0]?.cvssData.vectorString //
|
||||
.cve.metrics.cvssMetricV2[0]?.vectorString //
|
||||
""
|
||||
)
|
||||
}] | map({(.id): .cvss_vector}) | add
|
||||
' "$TEMP_DIR/filtered_cves.json" > "$TEMP_DIR/cvss_vectors.json"
|
||||
|
||||
"$ENRICH_SCRIPT" \
|
||||
--mode batch \
|
||||
--input "$TEMP_DIR/new_advisories.json" \
|
||||
--output "$TEMP_DIR/new_advisories.json" \
|
||||
--cvss-vectors "$TEMP_DIR/cvss_vectors.json"
|
||||
|
||||
echo ""
|
||||
echo "=== New Advisories ==="
|
||||
jq -r '.[] | " \(.id) [\(.severity)] - \(.title)"' "$TEMP_DIR/new_advisories.json"
|
||||
@@ -298,7 +359,7 @@ else
|
||||
jq -n --argjson advisories "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '{
|
||||
version: "1.0.0",
|
||||
updated: $now,
|
||||
description: "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD.",
|
||||
description: "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw and NanoClaw-related CVEs from NVD.",
|
||||
advisories: ($advisories | sort_by(.published) | reverse)
|
||||
}' > "$TEMP_DIR/updated_feed.json"
|
||||
fi
|
||||
@@ -308,16 +369,9 @@ if jq empty "$TEMP_DIR/updated_feed.json" 2>/dev/null; then
|
||||
# Update main feed
|
||||
cp "$TEMP_DIR/updated_feed.json" "$FEED_PATH"
|
||||
echo "✓ Updated: $FEED_PATH"
|
||||
|
||||
# Update skill feed
|
||||
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
|
||||
cp "$FEED_PATH" "$SKILL_FEED_PATH"
|
||||
echo "✓ Updated: $SKILL_FEED_PATH"
|
||||
|
||||
# Update public feed for local dev
|
||||
mkdir -p "$(dirname "$PUBLIC_FEED_PATH")"
|
||||
cp "$FEED_PATH" "$PUBLIC_FEED_PATH"
|
||||
echo "✓ Updated: $PUBLIC_FEED_PATH"
|
||||
|
||||
# Sync feed mirrors for local skill/public consumers.
|
||||
sync_feed_to_mirrors "$FEED_PATH" "create"
|
||||
|
||||
echo ""
|
||||
TOTAL_ADVISORIES=$(jq '.advisories | length' "$FEED_PATH")
|
||||
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# populate-local-wiki.sh
|
||||
# Generates wiki-derived public assets for local preview and CI parity.
|
||||
#
|
||||
# Usage: ./scripts/populate-local-wiki.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
WIKI_DIR="$PROJECT_ROOT/wiki"
|
||||
PUBLIC_WIKI_DIR="$PROJECT_ROOT/public/wiki"
|
||||
|
||||
if [ ! -d "$WIKI_DIR" ]; then
|
||||
echo "Error: wiki directory not found at $WIKI_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ClawSec Local Wiki Populator ==="
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
|
||||
node "$PROJECT_ROOT/scripts/generate-wiki-llms.mjs"
|
||||
|
||||
PAGE_COUNT=0
|
||||
if [ -d "$PUBLIC_WIKI_DIR" ]; then
|
||||
PAGE_COUNT=$(find "$PUBLIC_WIKI_DIR" -type f -path '*/llms.txt' ! -path "$PUBLIC_WIKI_DIR/llms.txt" | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
echo "Wiki llms index: $PUBLIC_WIKI_DIR/llms.txt"
|
||||
echo "Wiki llms pages: $PAGE_COUNT files under $PUBLIC_WIKI_DIR/<page>/llms.txt"
|
||||
@@ -76,13 +76,13 @@ fi
|
||||
# ESLint
|
||||
echo -e "\n${YELLOW}Running ESLint...${NC}"
|
||||
if $FIX_MODE; then
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --fix; then
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --ignore-pattern '.auto-claude/**' --fix; then
|
||||
check_pass "ESLint (with auto-fix)"
|
||||
else
|
||||
check_fail "ESLint found unfixable issues"
|
||||
fi
|
||||
else
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0; then
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --ignore-pattern '.auto-claude/**' --max-warnings 0; then
|
||||
check_pass "ESLint"
|
||||
else
|
||||
check_fail "ESLint found issues (run with --fix to auto-fix)"
|
||||
@@ -190,7 +190,7 @@ print_header "Security"
|
||||
# Trivy FS Scan
|
||||
if command -v trivy &> /dev/null; then
|
||||
echo -e "\n${YELLOW}Running Trivy filesystem scan...${NC}"
|
||||
if trivy fs . --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed; then
|
||||
if trivy fs . --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed --skip-dirs .auto-claude --skip-files clawsec-signing-private.pem; then
|
||||
check_pass "Trivy filesystem scan"
|
||||
else
|
||||
check_fail "Trivy found CRITICAL/HIGH vulnerabilities"
|
||||
|
||||
@@ -160,6 +160,6 @@ After release, confirm:
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See repository for details.
|
||||
GNU AGPL v3.0 or later - See repository for details.
|
||||
|
||||
Built by the [Prompt Security](https://prompt.security) team.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"description": "Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security",
|
||||
"keywords": ["release", "versioning", "deployment", "automation", "ci-cd", "skills"],
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# ClawSec ClawHub Checker
|
||||
|
||||
A ClawSec suite skill that enhances the guarded skill installer with ClawHub reputation checks and VirusTotal Code Insight integration.
|
||||
|
||||
## Purpose
|
||||
|
||||
Adds a second layer of security to skill installation by:
|
||||
1. Checking ClawHub's VirusTotal Code Insight reputation scores
|
||||
2. Analyzing skill age, author reputation, and download statistics
|
||||
3. Requiring double confirmation for suspicious skills
|
||||
4. Integrating with existing ClawSec advisory checks
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
clawsec-suite (base)
|
||||
└── clawsec-clawhub-checker (enhancement)
|
||||
├── enhanced_guarded_install.mjs - Main enhanced installer
|
||||
├── check_clawhub_reputation.mjs - Reputation checking logic
|
||||
├── setup_reputation_hook.mjs - Integration script
|
||||
└── hooks/ - Enhanced advisory guardian hook
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# First install the base suite
|
||||
npx clawhub install clawsec-suite
|
||||
|
||||
# Then install the checker
|
||||
npx clawhub install clawsec-clawhub-checker
|
||||
|
||||
# Run setup to integrate with existing suite
|
||||
node scripts/setup_reputation_hook.mjs
|
||||
|
||||
# Restart OpenClaw gateway
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Setup installs these scripts into `clawsec-suite/scripts`:
|
||||
- `enhanced_guarded_install.mjs`
|
||||
- `guarded_skill_install_wrapper.mjs` (drop-in wrapper)
|
||||
- `check_clawhub_reputation.mjs`
|
||||
|
||||
The original `guarded_skill_install.mjs` remains unchanged.
|
||||
|
||||
## Usage
|
||||
|
||||
### Enhanced Guarded Installer
|
||||
|
||||
```bash
|
||||
# Basic usage via wrapper (includes reputation checks)
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --version 1.0.0
|
||||
|
||||
# Direct usage (enhanced script)
|
||||
node scripts/enhanced_guarded_install.mjs --skill some-skill --version 1.0.0
|
||||
|
||||
# With reputation confirmation override
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation
|
||||
|
||||
# Adjust reputation threshold (default: 70)
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --reputation-threshold 80
|
||||
```
|
||||
|
||||
### Reputation Check Only
|
||||
|
||||
```bash
|
||||
# Check reputation without installation
|
||||
node scripts/check_clawhub_reputation.mjs some-skill 1.0.0 70
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0` - Safe to install
|
||||
- `42` - Advisory match found (requires `--confirm-advisory`)
|
||||
- `43` - Reputation warning (requires `--confirm-reputation`) - **NEW**
|
||||
- `1` - Error
|
||||
|
||||
## Reputation Signals Checked
|
||||
|
||||
1. **VirusTotal Code Insight** - Malicious code patterns
|
||||
2. **Skill Age** - New skills (<7 days) are riskier
|
||||
3. **Author Reputation** - Number of published skills
|
||||
4. **Update Frequency** - Stale skills (>90 days)
|
||||
5. **Download Statistics** - Low download counts
|
||||
6. **Version Existence** - Specified version availability
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
- `CLAWHUB_REPUTATION_THRESHOLD` - Minimum score (0-100, default: 70)
|
||||
|
||||
## Integration Points
|
||||
|
||||
1. **Enhanced `guarded_skill_install.mjs`** - Wraps original with reputation checks
|
||||
via `guarded_skill_install_wrapper.mjs` and `enhanced_guarded_install.mjs`
|
||||
2. **Updated advisory guardian hook** - Adds reputation warnings to alerts
|
||||
3. **Catalog entry in clawsec-suite** - Listed as available enhancement
|
||||
|
||||
## Development
|
||||
|
||||
### Files
|
||||
|
||||
- `SKILL.md` - Main documentation
|
||||
- `skill.json` - Skill metadata and SBOM
|
||||
- `scripts/enhanced_guarded_install.mjs` - Enhanced installer
|
||||
- `scripts/check_clawhub_reputation.mjs` - Reputation logic
|
||||
- `scripts/setup_reputation_hook.mjs` - Integration script
|
||||
- `hooks/clawsec-advisory-guardian/lib/reputation.mjs` - Hook module
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Test reputation check
|
||||
node scripts/check_clawhub_reputation.mjs clawsec-suite
|
||||
|
||||
# Test enhanced installer (dry run)
|
||||
node scripts/enhanced_guarded_install.mjs --skill test-skill --dry-run
|
||||
|
||||
# Test setup
|
||||
node scripts/setup_reputation_hook.mjs
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Reputation checks are **heuristic**, not definitive
|
||||
- **False positives** possible with legitimate novel skills
|
||||
- Always **review skill code** before overriding warnings
|
||||
- This is **defense-in-depth**, not replacement for advisory feeds
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3.0 or later - Part of the ClawSec security suite
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: clawsec-clawhub-checker
|
||||
version: 0.0.1
|
||||
description: ClawHub reputation checker for ClawSec suite. Enhances guarded skill installer with VirusTotal Code Insight reputation scores and additional safety checks.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "🛡️"
|
||||
requires:
|
||||
bins: [clawhub, curl, jq]
|
||||
depends_on: [clawsec-suite]
|
||||
---
|
||||
|
||||
# ClawSec ClawHub Checker
|
||||
|
||||
Enhances the ClawSec suite's guarded skill installer with ClawHub reputation checks. Adds a second layer of security by checking VirusTotal Code Insight scores and other reputation signals before allowing skill installation.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. **Wraps `clawhub install`** - Intercepts skill installation requests
|
||||
2. **Checks VirusTotal reputation** - Uses ClawHub's built-in VirusTotal Code Insight
|
||||
3. **Adds double confirmation** - For suspicious skills (reputation score below threshold)
|
||||
4. **Integrates with advisory feed** - Works alongside existing clawsec-suite advisories
|
||||
5. **Provides detailed reports** - Shows why a skill is flagged as suspicious
|
||||
|
||||
## Installation
|
||||
|
||||
This skill must be installed **after** `clawsec-suite`:
|
||||
|
||||
```bash
|
||||
# First install the suite
|
||||
npx clawhub@latest install clawsec-suite
|
||||
|
||||
# Then install the checker
|
||||
npx clawhub@latest install clawsec-clawhub-checker
|
||||
|
||||
# Run the setup script to integrate with clawsec-suite
|
||||
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs
|
||||
|
||||
# Restart OpenClaw gateway for changes to take effect
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
After setup, the checker adds `enhanced_guarded_install.mjs` and
|
||||
`guarded_skill_install_wrapper.mjs` under `clawsec-suite/scripts` and updates the advisory
|
||||
guardian hook. The original `guarded_skill_install.mjs` is not replaced.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Enhanced Guarded Installer
|
||||
|
||||
After setup, run the wrapper (drop-in path) or the enhanced script directly:
|
||||
```bash
|
||||
# Recommended drop-in wrapper
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --version 1.0.0
|
||||
|
||||
# Or call the enhanced script directly
|
||||
node scripts/enhanced_guarded_install.mjs --skill some-skill --version 1.0.0
|
||||
```
|
||||
|
||||
The enhanced flow:
|
||||
1. **Advisory check** (existing) - Checks clawsec advisory feed
|
||||
2. **Reputation check** (new) - Queries ClawHub for VirusTotal scores
|
||||
3. **Risk assessment** - Combines advisory + reputation signals
|
||||
4. **Double confirmation** - If risky, requires explicit `--confirm-reputation`
|
||||
|
||||
### Reputation Signals Checked
|
||||
|
||||
1. **VirusTotal Code Insight** - Malicious code patterns, external dependencies (Docker usage, network calls, eval usage, crypto keys)
|
||||
2. **Skill age & updates** - New skills vs established ones
|
||||
3. **Author reputation** - Other skills by same author
|
||||
4. **Download statistics** - Popularity signals
|
||||
|
||||
### Exit Codes
|
||||
|
||||
- `0` - Safe to install (no advisories, good reputation)
|
||||
- `42` - Advisory match found (existing behavior)
|
||||
- `43` - Reputation warning (new - requires `--confirm-reputation`)
|
||||
- `1` - Error
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
- `CLAWHUB_REPUTATION_THRESHOLD` - Minimum reputation score (0-100, default: 70)
|
||||
|
||||
## Integration with Existing Suite
|
||||
|
||||
The checker enhances but doesn't replace existing security:
|
||||
- **Advisory feed still primary** - Known malicious skills blocked first
|
||||
- **Reputation is secondary** - Unknown/suspicious skills get extra scrutiny
|
||||
- **Double confirmation preserved** - Both layers require explicit user approval
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Try to install a skill
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0
|
||||
|
||||
# Output might show:
|
||||
# WARNING: Skill "suspicious-skill" has low reputation score (45/100)
|
||||
# - Flagged by VirusTotal Code Insight: crypto keys, external APIs, eval usage
|
||||
# - Author has no other published skills
|
||||
# - Skill is less than 7 days old
|
||||
#
|
||||
# To install despite reputation warning, run:
|
||||
# node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation
|
||||
|
||||
# Install with confirmation
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation
|
||||
```
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- This is a **defense-in-depth** layer, not a replacement for advisory feeds
|
||||
- VirusTotal scores are **heuristic**, not definitive
|
||||
- **False positives possible** - Legitimate skills with novel patterns might be flagged
|
||||
- Always **review skill code** before installing with `--confirm-reputation`
|
||||
|
||||
## Current Limitations
|
||||
|
||||
### Missing OpenClaw Internal Check Data
|
||||
ClawHub shows two security badges on skill pages:
|
||||
1. **VirusTotal Code Insight** - ✅ Our checker catches these flags
|
||||
2. **OpenClaw internal check** - ❌ Not exposed via API (only on website)
|
||||
|
||||
Example from `clawsec-suite` page:
|
||||
- VirusTotal: "Benign" ✓
|
||||
- OpenClaw internal check: "The package is internally consistent with a feed-monitoring / advisory-guardian purpose, but a few operational details and optional bypasses deserve attention before installing."
|
||||
|
||||
**Our checker cannot access OpenClaw internal check warnings** as they're not exposed via `clawhub` CLI or API.
|
||||
|
||||
### Recommendation for ClawHub
|
||||
To enable complete reputation checking, ClawHub should expose internal check results via:
|
||||
- `clawhub inspect --json` endpoint
|
||||
- Additional API field for security tools
|
||||
- Or include in `clawhub install` warning output
|
||||
|
||||
### Workaround
|
||||
Our heuristic checks (skill age, author reputation, downloads, updates) provide similar risk assessment but miss specific operational warnings about bypasses, missing signatures, etc. Always check the ClawHub website for complete security assessment.
|
||||
|
||||
## Development
|
||||
|
||||
To modify the reputation checking logic, edit:
|
||||
- `scripts/enhanced_guarded_install.mjs` - Main enhanced installer
|
||||
- `scripts/check_clawhub_reputation.mjs` - Reputation checking logic
|
||||
- `hooks/clawsec-advisory-guardian/lib/reputation.mjs` - Hook integration
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3.0 or later - Part of the ClawSec security suite
|
||||
@@ -0,0 +1,99 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Check reputation for a skill
|
||||
* @param {string} skillName - Skill name
|
||||
* @param {string} version - Skill version
|
||||
* @returns {Promise<{safe: boolean, score: number, warnings: string[]}>}
|
||||
*/
|
||||
export async function checkReputation(skillName, version) {
|
||||
const result = {
|
||||
safe: true,
|
||||
score: 100,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Try to get skill slug from directory name or skill.json
|
||||
// For now, use skillName as slug (simplified)
|
||||
const skillSlug = skillName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Run the reputation check script
|
||||
// Current file is at: .../hooks/clawsec-advisory-guardian/lib/reputation.mjs
|
||||
// We need to go up 3 levels to get to the skill root directory
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const checkerDir = path.resolve(__dirname, '../../..');
|
||||
|
||||
const reputationCheck = spawnSync(
|
||||
"node",
|
||||
[
|
||||
`${checkerDir}/scripts/check_clawhub_reputation.mjs`,
|
||||
skillSlug,
|
||||
version || "",
|
||||
"70" // Default threshold
|
||||
],
|
||||
{ encoding: "utf-8", cwd: checkerDir }
|
||||
);
|
||||
|
||||
if (reputationCheck.status === 0) {
|
||||
try {
|
||||
const repResult = JSON.parse(reputationCheck.stdout);
|
||||
result.safe = repResult.safe;
|
||||
result.score = repResult.score;
|
||||
result.warnings = repResult.warnings;
|
||||
} catch (parseError) {
|
||||
result.warnings.push(`Failed to parse reputation result: ${parseError.message}`);
|
||||
result.score = 60;
|
||||
result.safe = result.score >= 70;
|
||||
}
|
||||
} else if (reputationCheck.status === 43) {
|
||||
// Reputation warning exit code
|
||||
try {
|
||||
const repResult = JSON.parse(reputationCheck.stdout);
|
||||
result.safe = false;
|
||||
result.score = repResult.score;
|
||||
result.warnings = repResult.warnings;
|
||||
} catch {
|
||||
result.safe = false;
|
||||
result.score = 50;
|
||||
result.warnings.push("Skill flagged by reputation check");
|
||||
}
|
||||
} else {
|
||||
// Error running check
|
||||
result.warnings.push(`Reputation check failed: ${reputationCheck.stderr || 'Unknown error'}`);
|
||||
result.score = 60;
|
||||
result.safe = result.score >= 70;
|
||||
}
|
||||
} catch (error) {
|
||||
result.warnings.push(`Reputation check error: ${error.message}`);
|
||||
result.score = 50;
|
||||
result.safe = result.score >= 70;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reputation warning for alert messages
|
||||
* @param {{score: number, warnings: string[]}} reputationInfo
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatReputationWarning(reputationInfo) {
|
||||
if (!reputationInfo || reputationInfo.score >= 70) return "";
|
||||
|
||||
const lines = [
|
||||
`\n⚠️ **REPUTATION WARNING** (Score: ${reputationInfo.score}/100)`,
|
||||
];
|
||||
|
||||
if (reputationInfo.warnings.length > 0) {
|
||||
lines.push("");
|
||||
reputationInfo.warnings.forEach(w => lines.push(`• ${w}`));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("This skill has low reputation score. Review carefully before installation.");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Check ClawHub reputation for a skill
|
||||
* @param {string} skillSlug - Skill slug to check
|
||||
* @param {string} version - Optional version
|
||||
* @param {number} threshold - Minimum reputation score (0-100)
|
||||
* @returns {Promise<{safe: boolean, score: number, warnings: string[], virustotal: string[]}>}
|
||||
*/
|
||||
export async function checkClawhubReputation(skillSlug, version, threshold = 70) {
|
||||
const result = {
|
||||
safe: true,
|
||||
score: 100, // Default score if no checks fail
|
||||
warnings: [],
|
||||
virustotal: [],
|
||||
};
|
||||
|
||||
// Input validation — reject anything that isn't a safe slug or semver
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(skillSlug)) {
|
||||
result.warnings.push(`Invalid skill slug: ${skillSlug}`);
|
||||
result.score = 0;
|
||||
result.safe = false;
|
||||
return result;
|
||||
}
|
||||
// Semver validation: supports major.minor.patch with optional pre-release and build metadata
|
||||
// Examples: 1.0.0, 1.0.0-alpha.1, 1.0.0-beta+20130313144700
|
||||
// More restrictive than full semver spec for security (prevents command injection)
|
||||
if (version && !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(version)) {
|
||||
result.warnings.push(`Invalid version format: ${version}`);
|
||||
result.score = 0;
|
||||
result.safe = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check 1: Try to inspect the skill via clawhub
|
||||
const inspectResult = spawnSync(
|
||||
"clawhub",
|
||||
["inspect", skillSlug, "--json"],
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
|
||||
if (inspectResult.status !== 0) {
|
||||
// Skill doesn't exist or can't be inspected
|
||||
result.warnings.push(`Skill "${skillSlug}" not found or cannot be inspected`);
|
||||
result.score = Math.min(result.score, 50);
|
||||
} else {
|
||||
try {
|
||||
const skillInfo = JSON.parse(inspectResult.stdout);
|
||||
|
||||
// Check 2: Skill age (new skills are riskier)
|
||||
if (skillInfo.skill?.createdAt) {
|
||||
const createdMs = skillInfo.skill.createdAt;
|
||||
const ageDays = (Date.now() - createdMs) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (ageDays < 7) {
|
||||
result.warnings.push(`Skill is less than 7 days old (${ageDays.toFixed(1)} days)`);
|
||||
result.score -= 15;
|
||||
} else if (ageDays < 30) {
|
||||
result.warnings.push(`Skill is less than 30 days old (${ageDays.toFixed(1)} days)`);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: Update frequency (stale skills are riskier)
|
||||
if (skillInfo.skill?.updatedAt && skillInfo.skill?.createdAt) {
|
||||
const updatedMs = skillInfo.skill.updatedAt;
|
||||
const createdMs = skillInfo.skill.createdAt;
|
||||
const updateAgeDays = (Date.now() - updatedMs) / (1000 * 60 * 60 * 24);
|
||||
const totalAgeDays = (Date.now() - createdMs) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (updateAgeDays > 90 && totalAgeDays > 90) {
|
||||
result.warnings.push(`Skill hasn't been updated in ${updateAgeDays.toFixed(0)} days`);
|
||||
result.score -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: Author reputation
|
||||
if (skillInfo.owner?.handle) {
|
||||
const authorResult = spawnSync(
|
||||
"clawhub",
|
||||
["search", skillInfo.owner.handle],
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
|
||||
if (authorResult.status === 0) {
|
||||
const lines = authorResult.stdout.trim().split('\n').filter(l => l);
|
||||
const skillCount = lines.length - 1; // First line is header
|
||||
|
||||
if (skillCount === 1) {
|
||||
result.warnings.push(`Author "${skillInfo.owner.handle}" has only 1 published skill`);
|
||||
result.score -= 10;
|
||||
} else if (skillCount < 3) {
|
||||
result.warnings.push(`Author "${skillInfo.owner.handle}" has only ${skillCount} published skills`);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 5: Download statistics
|
||||
if (skillInfo.skill?.stats?.downloads !== undefined) {
|
||||
const downloads = skillInfo.skill.stats.downloads;
|
||||
if (downloads < 10) {
|
||||
result.warnings.push(`Low download count: ${downloads}`);
|
||||
result.score -= 10;
|
||||
} else if (downloads < 100) {
|
||||
result.warnings.push(`Moderate download count: ${downloads}`);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
result.warnings.push(`Failed to parse skill information: ${parseError.message}`);
|
||||
result.score = Math.min(result.score, 60);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 6: Try installation to detect VirusTotal Code Insight warnings
|
||||
// Note: This approach has potential side effects:
|
||||
// - May download/cache skill metadata before declining
|
||||
// - Depends on clawhub's prompting behavior (sending "n\n" to decline)
|
||||
// - If clawhub inspect provided security flags, we'd use that instead
|
||||
// This is the only way to programmatically access VirusTotal warnings currently
|
||||
const installArgs = ["install", skillSlug];
|
||||
if (version) installArgs.push("--version", version);
|
||||
const installCheck = spawnSync("clawhub", installArgs, {
|
||||
input: "n\n", // Automatically decline the installation prompt
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
const output = (installCheck.stdout || "") + (installCheck.stderr || "");
|
||||
if (output.includes("suspicious") || output.includes("VirusTotal") || output.includes("flagged")) {
|
||||
result.virustotal.push("Flagged by ClawHub's VirusTotal Code Insight");
|
||||
result.score -= 40; // More severe penalty for VirusTotal flag
|
||||
|
||||
// Extract specific warnings
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.includes("Warning:") || line.includes("risky patterns") ||
|
||||
line.includes("crypto keys") || line.includes("external APIs") ||
|
||||
line.includes("eval") || line.includes("VirusTotal Code Insight")) {
|
||||
const cleanLine = line.trim().replace(/^⚠️\s*/, '').replace(/^\s*Warning:\s*/, '');
|
||||
if (cleanLine && !result.virustotal.includes(cleanLine)) {
|
||||
result.virustotal.push(cleanLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7: If version specified, check if it exists
|
||||
if (version) {
|
||||
const versionCheck = spawnSync(
|
||||
"clawhub",
|
||||
["inspect", skillSlug, "--version", version, "--json"],
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
|
||||
if (versionCheck.status !== 0) {
|
||||
result.warnings.push(`Version ${version} not found for skill ${skillSlug}`);
|
||||
result.score -= 20;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score is within bounds
|
||||
result.score = Math.max(0, Math.min(100, result.score));
|
||||
result.safe = result.score >= threshold;
|
||||
|
||||
// Add summary warning if below threshold
|
||||
if (!result.safe) {
|
||||
result.warnings.unshift(`Reputation score ${result.score}/100 below threshold ${threshold}/100`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.warnings.push(`Reputation check error: ${error.message}`);
|
||||
result.score = 50;
|
||||
result.safe = result.score >= threshold;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// CLI interface for direct usage
|
||||
const isCliEntrypoint =
|
||||
process.argv[1] !== undefined &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||
|
||||
if (isCliEntrypoint) {
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
console.error("Usage: node check_clawhub_reputation.mjs <skill-slug> [version] [threshold]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const skillSlug = args[0];
|
||||
const version = args[1] || "";
|
||||
let threshold = 70;
|
||||
if (args[2] !== undefined) {
|
||||
const parsedThreshold = parseInt(args[2], 10);
|
||||
if (!Number.isInteger(parsedThreshold) || parsedThreshold < 0 || parsedThreshold > 100) {
|
||||
console.error(
|
||||
`Invalid threshold: "${args[2]}". Threshold must be an integer between 0 and 100.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
threshold = parsedThreshold;
|
||||
}
|
||||
|
||||
const result = await checkClawhubReputation(skillSlug, version, threshold);
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.safe) {
|
||||
process.exit(43);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { checkClawhubReputation } from "./check_clawhub_reputation.mjs";
|
||||
|
||||
const EXIT_ADVISORY_CONFIRM_REQUIRED = 42;
|
||||
const EXIT_REPUTATION_CONFIRM_REQUIRED = 43;
|
||||
|
||||
function printUsage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill <skill-name> [--version <version>] [--confirm-advisory] [--confirm-reputation] [--dry-run] [--reputation-threshold <score>]",
|
||||
"",
|
||||
"Examples:",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill helper-plus --version 1.0.1",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill helper-plus --version 1.0.1 --confirm-advisory --confirm-reputation",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill suspicious-skill --reputation-threshold 80",
|
||||
"",
|
||||
"Exit codes:",
|
||||
" 0 success / no advisory or reputation block",
|
||||
" 42 advisory matched and second confirmation is required",
|
||||
" 43 reputation warning and second confirmation is required",
|
||||
" 1 error",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
// Parse and validate CLAWHUB_REPUTATION_THRESHOLD environment variable
|
||||
let defaultThreshold = 70;
|
||||
const envThreshold = process.env.CLAWHUB_REPUTATION_THRESHOLD;
|
||||
|
||||
if (envThreshold !== undefined && envThreshold !== "") {
|
||||
const parsedEnv = parseInt(envThreshold, 10);
|
||||
if (Number.isNaN(parsedEnv) || parsedEnv < 0 || parsedEnv > 100) {
|
||||
throw new Error(
|
||||
`Invalid CLAWHUB_REPUTATION_THRESHOLD environment variable: "${envThreshold}". Must be between 0 and 100.`
|
||||
);
|
||||
}
|
||||
defaultThreshold = parsedEnv;
|
||||
}
|
||||
|
||||
const parsed = {
|
||||
skill: "",
|
||||
version: "",
|
||||
confirmAdvisory: false,
|
||||
confirmReputation: false,
|
||||
dryRun: false,
|
||||
reputationThreshold: defaultThreshold,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === "--skill") {
|
||||
parsed.skill = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--version") {
|
||||
parsed.version = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm-advisory") {
|
||||
parsed.confirmAdvisory = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm-reputation") {
|
||||
parsed.confirmReputation = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--reputation-threshold") {
|
||||
parsed.reputationThreshold = parseInt(String(argv[i + 1] ?? "70"), 10);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--help" || token === "-h") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
|
||||
if (!parsed.skill) {
|
||||
throw new Error("Missing required argument: --skill");
|
||||
}
|
||||
// Must start with alphanumeric, then can contain hyphens (matches check_clawhub_reputation.mjs validation)
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(parsed.skill)) {
|
||||
throw new Error("Invalid --skill value. Must start with a letter or digit, followed by lowercase letters, digits, and hyphens.");
|
||||
}
|
||||
if (parsed.version && !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(parsed.version)) {
|
||||
throw new Error(
|
||||
"Invalid --version value. Must be semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.45)."
|
||||
);
|
||||
}
|
||||
if (parsed.reputationThreshold < 0 || parsed.reputationThreshold > 100 || Number.isNaN(parsed.reputationThreshold)) {
|
||||
throw new Error("Invalid --reputation-threshold value. Must be between 0 and 100.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildOriginalArgs(argv) {
|
||||
// Filter out reputation-specific arguments that the original script doesn't understand
|
||||
const originalArgs = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === "--confirm-reputation" || token === "--reputation-threshold") {
|
||||
// Skip reputation-specific flags
|
||||
if (token === "--reputation-threshold" && i + 1 < argv.length) {
|
||||
// Also skip the value associated with --reputation-threshold
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
originalArgs.push(token);
|
||||
}
|
||||
|
||||
return originalArgs;
|
||||
}
|
||||
|
||||
async function runOriginalGuardedInstall(args) {
|
||||
// Find the original guarded_skill_install.mjs from clawsec-suite
|
||||
const suiteDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const originalScript = path.join(suiteDir, "scripts", "guarded_skill_install.mjs");
|
||||
|
||||
try {
|
||||
await fs.access(originalScript);
|
||||
} catch {
|
||||
throw new Error(`Original guarded_skill_install.mjs not found at ${originalScript}. Is clawsec-suite installed?`);
|
||||
}
|
||||
|
||||
// Pass through environment without modification
|
||||
// The original guarded_skill_install.mjs handles --confirm-advisory properly
|
||||
const child = spawnSync(
|
||||
"node",
|
||||
[originalScript, ...args.originalArgs],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
cwd: suiteDir,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
exitCode: child.status ?? 1,
|
||||
signal: child.signal,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const args = parseArgs(cliArgs);
|
||||
|
||||
// Build args for original script (excluding reputation-specific args)
|
||||
args.originalArgs = buildOriginalArgs(cliArgs);
|
||||
|
||||
// Step 1: Check reputation (unless already confirmed)
|
||||
if (!args.confirmReputation) {
|
||||
console.log(`Checking ClawHub reputation for ${args.skill}${args.version ? `@${args.version}` : ""}...`);
|
||||
|
||||
const reputationResult = await checkClawhubReputation(args.skill, args.version, args.reputationThreshold);
|
||||
|
||||
if (!reputationResult.safe) {
|
||||
console.error("\n" + "=".repeat(80));
|
||||
console.error("REPUTATION WARNING");
|
||||
console.error("=".repeat(80));
|
||||
console.error(`Skill "${args.skill}" has low reputation score: ${reputationResult.score}/100`);
|
||||
console.error(`Threshold: ${args.reputationThreshold}/100`);
|
||||
console.error("");
|
||||
|
||||
if (reputationResult.warnings.length > 0) {
|
||||
console.error("Warnings:");
|
||||
reputationResult.warnings.forEach(w => console.error(` • ${w}`));
|
||||
console.error("");
|
||||
}
|
||||
|
||||
if (reputationResult.virustotal) {
|
||||
console.error("VirusTotal Code Insight flags:");
|
||||
reputationResult.virustotal.forEach(v => console.error(` • ${v}`));
|
||||
console.error("");
|
||||
}
|
||||
|
||||
console.error("To install despite reputation warning, run with --confirm-reputation flag:");
|
||||
console.error(` node ${process.argv[1]} --skill ${args.skill}${args.version ? ` --version ${args.version}` : ""} --confirm-reputation`);
|
||||
console.error("");
|
||||
console.error("=".repeat(80));
|
||||
|
||||
process.exit(EXIT_REPUTATION_CONFIRM_REQUIRED);
|
||||
}
|
||||
|
||||
console.log(`✓ Reputation check passed: ${reputationResult.score}/100`);
|
||||
} else {
|
||||
console.log(`⚠️ Reputation confirmation override enabled for ${args.skill}`);
|
||||
}
|
||||
|
||||
// Step 2: Run original guarded installer (handles advisory checks)
|
||||
console.log("\nRunning advisory checks...");
|
||||
const result = await runOriginalGuardedInstall(args);
|
||||
|
||||
if (result.exitCode !== 0 && result.exitCode !== EXIT_ADVISORY_CONFIRM_REQUIRED) {
|
||||
process.exit(result.exitCode);
|
||||
}
|
||||
|
||||
// If we get here, either success (0) or advisory confirmation required (42)
|
||||
process.exit(result.exitCode);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
async function main() {
|
||||
console.log("Setting up ClawHub reputation checker integration...");
|
||||
|
||||
// Paths
|
||||
const suiteDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const checkerDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-clawhub-checker");
|
||||
const hookLibDir = path.join(suiteDir, "hooks", "clawsec-advisory-guardian", "lib");
|
||||
const suiteScriptsDir = path.join(suiteDir, "scripts");
|
||||
|
||||
try {
|
||||
// Check if clawsec-suite is installed
|
||||
await fs.access(suiteDir);
|
||||
console.log(`✓ Found clawsec-suite at ${suiteDir}`);
|
||||
|
||||
// Check if hook lib directory exists
|
||||
await fs.access(hookLibDir);
|
||||
console.log(`✓ Found advisory guardian hook at ${hookLibDir}`);
|
||||
|
||||
// Copy reputation module to hook lib
|
||||
const reputationModuleSrc = path.join(checkerDir, "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs");
|
||||
const reputationModuleDst = path.join(hookLibDir, "reputation.mjs");
|
||||
|
||||
await fs.copyFile(reputationModuleSrc, reputationModuleDst);
|
||||
console.log(`✓ Copied reputation module to ${reputationModuleDst}`);
|
||||
|
||||
// Update hook handler to import reputation module
|
||||
const hookHandlerPath = path.join(suiteDir, "hooks", "clawsec-advisory-guardian", "handler.ts");
|
||||
let handlerContent = await fs.readFile(hookHandlerPath, "utf8");
|
||||
|
||||
// WARNING: This setup script uses string manipulation to modify handler.ts
|
||||
// This is fragile and may break if the handler structure changes
|
||||
// Consider using AST-based transformation or manual integration for production use
|
||||
let handlerChanged = false;
|
||||
const importLine = "import { checkReputation } from \"./lib/reputation.mjs\";";
|
||||
const reputationMarker = "// ClawHub reputation check for matched skills";
|
||||
|
||||
if (!handlerContent.includes(importLine)) {
|
||||
// Add import after other imports
|
||||
const importIndex = handlerContent.lastIndexOf("import");
|
||||
if (importIndex === -1) {
|
||||
throw new Error("Could not find import statements in handler.ts. Manual integration required.");
|
||||
}
|
||||
|
||||
const lineEndIndex = handlerContent.indexOf("\n", importIndex);
|
||||
handlerContent = handlerContent.slice(0, lineEndIndex + 1) + `${importLine}\n` + handlerContent.slice(lineEndIndex + 1);
|
||||
handlerChanged = true;
|
||||
} else {
|
||||
console.log("✓ Hook handler already imports reputation module");
|
||||
}
|
||||
|
||||
if (!handlerContent.includes(reputationMarker)) {
|
||||
const findMatchesAnchors = [
|
||||
{ line: "const allMatches = findMatches(feed, installedSkills);", variable: "allMatches" },
|
||||
{ line: "const matches = findMatches(feed, installedSkills);", variable: "matches" },
|
||||
];
|
||||
const matchedAnchor = findMatchesAnchors.find((entry) => handlerContent.includes(entry.line));
|
||||
|
||||
if (!matchedAnchor) {
|
||||
throw new Error(
|
||||
"Could not find findMatches assignment in handler.ts. Refusing partial setup. Manual integration required."
|
||||
);
|
||||
}
|
||||
|
||||
const anchorIndex = handlerContent.indexOf(matchedAnchor.line);
|
||||
const insertIndex = handlerContent.indexOf("\n", anchorIndex) + 1;
|
||||
const reputationCheckCode = `
|
||||
${reputationMarker}
|
||||
for (const match of ${matchedAnchor.variable}) {
|
||||
const repResult = await checkReputation(match.skill.name, match.skill.version);
|
||||
if (!repResult.safe) {
|
||||
match.reputationWarning = true;
|
||||
match.reputationScore = repResult.score;
|
||||
match.reputationWarnings = repResult.warnings;
|
||||
}
|
||||
}
|
||||
`;
|
||||
handlerContent = handlerContent.slice(0, insertIndex) + reputationCheckCode + handlerContent.slice(insertIndex);
|
||||
handlerChanged = true;
|
||||
} else {
|
||||
console.log("✓ Hook handler already has reputation scan block");
|
||||
}
|
||||
|
||||
if (handlerChanged) {
|
||||
await fs.writeFile(hookHandlerPath, handlerContent);
|
||||
console.log("✓ Updated hook handler with reputation checks");
|
||||
} else {
|
||||
console.log("✓ Hook handler already has required reputation integration");
|
||||
}
|
||||
|
||||
// Copy enhanced installer and reputation checker scripts
|
||||
const enhancedInstallerSrc = path.join(checkerDir, "scripts", "enhanced_guarded_install.mjs");
|
||||
const enhancedInstallerDst = path.join(suiteDir, "scripts", "enhanced_guarded_install.mjs");
|
||||
const reputationCheckSrc = path.join(checkerDir, "scripts", "check_clawhub_reputation.mjs");
|
||||
const reputationCheckDst = path.join(suiteScriptsDir, "check_clawhub_reputation.mjs");
|
||||
|
||||
await fs.copyFile(enhancedInstallerSrc, enhancedInstallerDst);
|
||||
console.log(`✓ Installed enhanced guarded installer at ${enhancedInstallerDst}`);
|
||||
|
||||
await fs.copyFile(reputationCheckSrc, reputationCheckDst);
|
||||
console.log(`✓ Installed reputation check script at ${reputationCheckDst}`);
|
||||
|
||||
// Create wrapper script that uses enhanced installer by default
|
||||
const wrapperScript = `#!/usr/bin/env node
|
||||
|
||||
// Wrapper that uses enhanced guarded installer with reputation checks
|
||||
// This replaces the original guarded_skill_install.mjs in usage
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const enhancedScript = path.join(__dirname, "enhanced_guarded_install.mjs");
|
||||
|
||||
const result = spawnSync("node", [enhancedScript, ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
`;
|
||||
|
||||
const wrapperPath = path.join(suiteDir, "scripts", "guarded_skill_install_wrapper.mjs");
|
||||
await fs.writeFile(wrapperPath, wrapperScript);
|
||||
await fs.chmod(wrapperPath, 0o755);
|
||||
console.log(`✓ Created wrapper script at ${wrapperPath}`);
|
||||
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("SETUP COMPLETE");
|
||||
console.log("=".repeat(80));
|
||||
console.log("\nThe ClawHub reputation checker has been integrated with clawsec-suite.");
|
||||
console.log("\nWhat changed:");
|
||||
console.log("1. Enhanced guarded installer with reputation checks installed");
|
||||
console.log("2. Reputation check helper script installed");
|
||||
console.log("3. Advisory guardian hook updated to include reputation warnings");
|
||||
console.log("4. Wrapper script created for backward compatibility");
|
||||
console.log("\nUsage:");
|
||||
console.log(" node scripts/enhanced_guarded_install.mjs --skill <name> [--version <ver>]");
|
||||
console.log(" node scripts/guarded_skill_install_wrapper.mjs --skill <name> [--version <ver>]");
|
||||
console.log("\nNew exit code: 43 = Reputation warning (requires --confirm-reputation)");
|
||||
console.log("\nRestart OpenClaw gateway for hook changes to take effect.");
|
||||
console.log("=".repeat(80));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Setup failed:", error.message);
|
||||
console.error("\nMake sure:");
|
||||
console.error("1. clawsec-suite is installed (npx clawhub install clawsec-suite)");
|
||||
console.error("2. You have write permissions to the suite directory");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "clawsec-clawhub-checker",
|
||||
"version": "0.0.1",
|
||||
"description": "ClawHub reputation checker for ClawSec suite. Enhances guarded skill installer with VirusTotal Code Insight reputation scores and additional safety checks.",
|
||||
"author": "abutbul",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
"keywords": [
|
||||
"security",
|
||||
"reputation",
|
||||
"clawhub",
|
||||
"virustotal",
|
||||
"skills",
|
||||
"installer",
|
||||
"verification",
|
||||
"defense-in-depth",
|
||||
"openclaw"
|
||||
],
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "Skill documentation and usage guide"
|
||||
},
|
||||
{
|
||||
"path": "scripts/enhanced_guarded_install.mjs",
|
||||
"required": true,
|
||||
"description": "Enhanced guarded installer with reputation checks"
|
||||
},
|
||||
{
|
||||
"path": "scripts/check_clawhub_reputation.mjs",
|
||||
"required": true,
|
||||
"description": "ClawHub reputation checking logic"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_reputation_hook.mjs",
|
||||
"required": true,
|
||||
"description": "Setup script to enhance existing advisory guardian hook"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/reputation.mjs",
|
||||
"required": true,
|
||||
"description": "Reputation checking module for advisory guardian hook"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"required": false,
|
||||
"description": "Additional documentation and development guide"
|
||||
},
|
||||
{
|
||||
"path": "test/reputation_check.test.mjs",
|
||||
"required": false,
|
||||
"description": "Test suite for reputation checking functionality"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"clawsec-suite": ">=0.0.10"
|
||||
},
|
||||
"integration": {
|
||||
"clawsec-suite": {
|
||||
"enhances": [
|
||||
"guarded_skill_install.mjs",
|
||||
"clawsec-advisory-guardian hook"
|
||||
],
|
||||
"adds_exit_codes": {
|
||||
"43": "Reputation warning - requires --confirm-reputation"
|
||||
},
|
||||
"adds_arguments": [
|
||||
"--confirm-reputation",
|
||||
"--reputation-threshold"
|
||||
]
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"emoji": "🛡️",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": ["clawhub", "curl", "jq"]
|
||||
},
|
||||
"triggers": [
|
||||
"clawhub reputation",
|
||||
"skill reputation check",
|
||||
"virustotal skill check",
|
||||
"safe skill install",
|
||||
"check skill safety",
|
||||
"skill security score"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Reputation check tests for clawsec-clawhub-checker.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Input validation (command injection prevention)
|
||||
* - Reputation scoring with mocked clawhub output
|
||||
* - formatReputationWarning output formatting
|
||||
* - Enhanced installer argument parsing
|
||||
*
|
||||
* Run: node skills/clawsec-clawhub-checker/test/reputation_check.test.mjs
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CHECKER_SCRIPT = path.resolve(__dirname, "..", "scripts", "check_clawhub_reputation.mjs");
|
||||
const ENHANCED_INSTALL_SCRIPT = path.resolve(__dirname, "..", "scripts", "enhanced_guarded_install.mjs");
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount++;
|
||||
console.log(`\u2713 ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`\u2717 ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function runScript(scriptPath, args, env) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("node", [scriptPath, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid skill slug is rejected (command injection prevention)
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidSlugRejected() {
|
||||
const testName = "reputation_check: invalid slug with shell metacharacters is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test; rm -rf /', '', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false && parsed.warnings.some(w => w.includes("Invalid skill slug"))) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected score 0 with invalid slug warning, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid version format is rejected (command injection prevention)
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidVersionRejected() {
|
||||
const testName = "reputation_check: invalid version with shell metacharacters is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0; curl evil.com', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false && parsed.warnings.some(w => w.includes("Invalid version format"))) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected score 0 with invalid version warning, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Valid slug and version pass input validation
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testValidInputsAccepted() {
|
||||
const testName = "reputation_check: valid slug and semver pass input validation";
|
||||
try {
|
||||
// clawhub is not installed, so the check will fail at the inspect step,
|
||||
// but it should NOT fail at input validation
|
||||
const result = await runScript(CHECKER_SCRIPT, ['my-test-skill', '1.0.0', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Should not contain input validation errors
|
||||
const hasInputError = parsed.warnings.some(
|
||||
w => w.includes("Invalid skill slug") || w.includes("Invalid version format")
|
||||
);
|
||||
if (!hasInputError) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Valid inputs were rejected: ${JSON.stringify(parsed.warnings)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Slug with uppercase or special chars is rejected
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testUppercaseSlugRejected() {
|
||||
const testName = "reputation_check: uppercase slug is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['Test-Skill', '1.0.0', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected uppercase slug to be rejected, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Empty slug shows usage error
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEmptySlugShowsUsage() {
|
||||
const testName = "reputation_check: empty slug shows usage error";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, []);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Usage:")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with usage message, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Version with pre-release tag is accepted
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testPreReleaseVersionAccepted() {
|
||||
const testName = "reputation_check: pre-release version format is accepted";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0-beta.1', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasVersionError = parsed.warnings.some(w => w.includes("Invalid version format"));
|
||||
if (!hasVersionError) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Pre-release version was rejected: ${JSON.stringify(parsed.warnings)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: CLI entrypoint guard works when script path is relative
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testRelativePathCliEntrypointWorks() {
|
||||
const testName = "reputation_check: CLI entrypoint works with relative script path";
|
||||
try {
|
||||
const relativeCheckerScript = path.relative(process.cwd(), CHECKER_SCRIPT);
|
||||
const result = await runScript(relativeCheckerScript, ['bad slug', '', '70']);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output with relative script path: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.code === 43 &&
|
||||
parsed.safe === false &&
|
||||
parsed.warnings.some((w) => w.includes("Invalid skill slug"))
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 43 with invalid slug warning via relative path, got code ${result.code}: ${JSON.stringify(parsed)}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid threshold format is rejected in CLI mode
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidThresholdRejected() {
|
||||
const testName = "reputation_check: invalid threshold is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0', 'abc']);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid threshold")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 1 with invalid threshold message, got code ${result.code}: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer rejects invalid skill name
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidSkill() {
|
||||
const testName = "enhanced_install: rejects skill name with invalid characters";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, ['--skill', 'bad skill!']);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --skill value")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with invalid skill error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer requires --skill argument
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRequiresSkill() {
|
||||
const testName = "enhanced_install: requires --skill argument";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, []);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Missing required argument")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with missing argument error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer rejects invalid threshold
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidThreshold() {
|
||||
const testName = "enhanced_install: rejects invalid reputation threshold";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, [
|
||||
'--skill', 'test-skill', '--reputation-threshold', '150'
|
||||
]);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --reputation-threshold")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with invalid threshold error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: formatReputationWarning
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testFormatReputationWarning() {
|
||||
const testName = "reputation: formatReputationWarning formats correctly";
|
||||
try {
|
||||
const { formatReputationWarning } = await import(
|
||||
path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs")
|
||||
);
|
||||
|
||||
// Safe reputation — should return empty
|
||||
const safeResult = formatReputationWarning({ score: 80, warnings: [] });
|
||||
if (safeResult !== "") {
|
||||
fail(testName, `Expected empty string for safe score, got: "${safeResult}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unsafe reputation — should contain warning
|
||||
const unsafeResult = formatReputationWarning({ score: 45, warnings: ["Low downloads", "New author"] });
|
||||
if (
|
||||
unsafeResult.includes("REPUTATION WARNING") &&
|
||||
unsafeResult.includes("45/100") &&
|
||||
unsafeResult.includes("Low downloads") &&
|
||||
unsafeResult.includes("New author")
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Unexpected format: "${unsafeResult}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: formatReputationWarning handles null/undefined
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testFormatReputationWarningNull() {
|
||||
const testName = "reputation: formatReputationWarning handles null input";
|
||||
try {
|
||||
const { formatReputationWarning } = await import(
|
||||
path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs")
|
||||
);
|
||||
|
||||
const nullResult = formatReputationWarning(null);
|
||||
const undefinedResult = formatReputationWarning(undefined);
|
||||
|
||||
if (nullResult === "" && undefinedResult === "") {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected empty for null/undefined, got: "${nullResult}", "${undefinedResult}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer validates --version even with --confirm-reputation
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidVersion() {
|
||||
const testName = "enhanced_install: rejects invalid version format even with --confirm-reputation";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, [
|
||||
'--skill', 'test-skill', '--version', '1.0.0;rm -rf /', '--confirm-reputation'
|
||||
]);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --version value")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 1 with invalid version message, got code ${result.code}: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main test runner
|
||||
// -----------------------------------------------------------------------------
|
||||
async function runTests() {
|
||||
console.log("=== ClawSec ClawHub Checker Tests ===\n");
|
||||
|
||||
await testInvalidSlugRejected();
|
||||
await testInvalidVersionRejected();
|
||||
await testValidInputsAccepted();
|
||||
await testUppercaseSlugRejected();
|
||||
await testEmptySlugShowsUsage();
|
||||
await testPreReleaseVersionAccepted();
|
||||
await testRelativePathCliEntrypointWorks();
|
||||
await testInvalidThresholdRejected();
|
||||
await testEnhancedInstallerRejectsInvalidSkill();
|
||||
await testEnhancedInstallerRequiresSkill();
|
||||
await testEnhancedInstallerRejectsInvalidVersion();
|
||||
await testEnhancedInstallerRejectsInvalidThreshold();
|
||||
await testFormatReputationWarning();
|
||||
await testFormatReputationWarningNull();
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
console.error("Test runner failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the ClawSec Feed skill 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.5] - 2026-02-28
|
||||
|
||||
### Added
|
||||
|
||||
- Exploitability-focused advisory guidance, including filtering and prioritization examples.
|
||||
- Notification examples that include exploitability context and rationale.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clarified exploitability scoring guidance to match runtime values (`high|medium|low|unknown`).
|
||||
- Updated response-priority guidance to align with exploitability-first triage.
|
||||
- De-duplicated exploitability filtering guidance in `SKILL.md` by pointing to canonical docs in `wiki/exploitability-scoring.md` and `clawsec-suite`.
|
||||
@@ -62,4 +62,4 @@ HIGH - GA-2026-016: Vulnerable skill "data-helper"
|
||||
|
||||
## License
|
||||
|
||||
MIT License - [Prompt Security](https://prompt.security)
|
||||
GNU AGPL v3.0 or later - [Prompt Security](https://prompt.security)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-feed
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
description: Security advisory feed with automated NVD CVE polling for OpenClaw-related vulnerabilities. Updated daily.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"📡","category":"security"}}
|
||||
@@ -318,7 +318,9 @@ curl -sSL --fail --show-error --retry 3 --retry-delay 1 "$FEED_URL"
|
||||
"description": "Skill sends user data to external server",
|
||||
"affected": ["helper-plus@1.0.0", "helper-plus@1.0.1"],
|
||||
"action": "Remove immediately",
|
||||
"published": "2026-02-01T10:00:00Z"
|
||||
"published": "2026-02-01T10:00:00Z",
|
||||
"exploitability_score": "critical",
|
||||
"exploitability_rationale": "Trivially exploitable through normal skill usage; no special conditions required. Active exploitation observed in the wild."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -385,6 +387,42 @@ fi
|
||||
echo "$RECENT"
|
||||
```
|
||||
|
||||
### Filter by exploitability score
|
||||
|
||||
Shared exploitability prioritization guidance is maintained in:
|
||||
|
||||
- [`wiki/exploitability-scoring.md`](../../wiki/exploitability-scoring.md)
|
||||
- [`skills/clawsec-suite/SKILL.md`](../clawsec-suite/SKILL.md) ("Quick feed check")
|
||||
|
||||
### Get exploitability context for an advisory
|
||||
|
||||
```bash
|
||||
# Show exploitability details for a specific CVE
|
||||
CVE_ID="CVE-2026-27488"
|
||||
echo "$FEED" | jq --arg cve "$CVE_ID" '.advisories[] | select(.id == $cve) | {
|
||||
id: .id,
|
||||
severity: .severity,
|
||||
exploitability_score: .exploitability_score,
|
||||
exploitability_rationale: .exploitability_rationale,
|
||||
title: .title
|
||||
}'
|
||||
```
|
||||
|
||||
### Prioritize advisories by exploitability
|
||||
|
||||
```bash
|
||||
# Sort advisories by exploitability (critical → high → medium → low)
|
||||
# This helps agents focus on the most immediately actionable threats
|
||||
echo "$FEED" | jq '[.advisories[] | select(.exploitability_score != null)] |
|
||||
sort_by(
|
||||
if .exploitability_score == "critical" then 0
|
||||
elif .exploitability_score == "high" then 1
|
||||
elif .exploitability_score == "medium" then 2
|
||||
elif .exploitability_score == "low" then 3
|
||||
else 4 end
|
||||
)'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Reference Installed Skills
|
||||
@@ -476,23 +514,75 @@ done
|
||||
|
||||
---
|
||||
|
||||
## Prioritizing High-Exploitability Threats
|
||||
|
||||
**IMPORTANT:** When reviewing advisories, always prioritize by **exploitability score** in addition to severity. The exploitability score indicates how easily a vulnerability can be exploited in practice, helping you focus on the most actionable threats.
|
||||
|
||||
### Exploitability Priority Levels
|
||||
|
||||
| Exploitability | Meaning | Action Priority |
|
||||
|----------------|---------|-----------------|
|
||||
| `high` | Trivially or easily exploitable with public tooling | **Immediate notification** |
|
||||
| `medium` | Exploitable but requires specific conditions | **Standard notification** |
|
||||
| `low` | Difficult to exploit or theoretical | **Low priority notification** |
|
||||
|
||||
### How to Use Exploitability in Notifications
|
||||
|
||||
1. **Filter for high-exploitability first:**
|
||||
```bash
|
||||
# Get high exploitability advisories
|
||||
echo "$FEED" | jq '.advisories[] | select(.exploitability_score == "high")'
|
||||
```
|
||||
|
||||
2. **Include exploitability in notifications:**
|
||||
```
|
||||
📡 ClawSec Feed: High-exploitability alert
|
||||
|
||||
CRITICAL - CVE-2026-27488 (Exploitability: HIGH)
|
||||
→ Trivially exploitable RCE in skill-loader v2.1.0
|
||||
→ Public exploit code available
|
||||
→ Recommended action: Immediate removal or upgrade to v2.1.1
|
||||
```
|
||||
|
||||
3. **Prioritize by both severity AND exploitability:**
|
||||
- A HIGH severity + HIGH exploitability CVE is more urgent than a CRITICAL severity + LOW exploitability CVE
|
||||
- Focus user attention on threats that are both severe and easily exploitable
|
||||
- Include the exploitability rationale to help users understand the risk context
|
||||
|
||||
### Example Notification Priority Order
|
||||
|
||||
When multiple advisories exist, present them in this order:
|
||||
1. **Critical severity + High exploitability** - most urgent
|
||||
2. **High severity + High exploitability**
|
||||
3. **Critical severity + Medium/Low exploitability**
|
||||
4. **High severity + Medium/Low exploitability**
|
||||
5. **Medium/Low severity** (any exploitability)
|
||||
|
||||
This ensures you alert users to the most actionable, immediately dangerous threats first.
|
||||
|
||||
---
|
||||
|
||||
## When to Notify Your User
|
||||
|
||||
**Notify Immediately (Critical):**
|
||||
- New critical advisory affecting an installed skill
|
||||
- Active exploitation detected
|
||||
- **High exploitability score** (regardless of severity)
|
||||
|
||||
**Notify Soon (High):**
|
||||
- New high-severity advisory affecting installed skills
|
||||
- Failed to fetch advisory feed (network issue?)
|
||||
- Medium exploitability with high severity
|
||||
|
||||
**Notify at Next Interaction (Medium):**
|
||||
- New medium-severity advisories
|
||||
- General security updates
|
||||
- Low exploitability advisories
|
||||
|
||||
**Log Only (Low/Info):**
|
||||
- Low-severity advisories (mention if user asks)
|
||||
- Feed checked, no new advisories
|
||||
- Theoretical vulnerabilities (low exploitability, low severity)
|
||||
|
||||
---
|
||||
|
||||
@@ -503,11 +593,13 @@ done
|
||||
```
|
||||
📡 ClawSec Feed: 2 new advisories since last check
|
||||
|
||||
CRITICAL - GA-2026-015: Malicious prompt pattern "ignore-all"
|
||||
CRITICAL - GA-2026-015: Malicious prompt pattern "ignore-all" (Exploitability: HIGH)
|
||||
→ Detected prompt injection technique. Update your system prompt defenses.
|
||||
→ Exploitability: Easily exploitable with publicly documented techniques.
|
||||
|
||||
HIGH - GA-2026-016: Vulnerable skill "data-helper" v1.2.0
|
||||
HIGH - GA-2026-016: Vulnerable skill "data-helper" v1.2.0 (Exploitability: MEDIUM)
|
||||
→ You have this installed! Recommended action: Update to v1.2.1 or remove.
|
||||
→ Exploitability: Requires specific configuration; not trivially exploitable.
|
||||
```
|
||||
|
||||
### If nothing new:
|
||||
@@ -671,6 +763,6 @@ fi
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See repository for details.
|
||||
GNU AGPL v3.0 or later - See repository for details.
|
||||
|
||||
Built with 📡 by the [Prompt Security](https://prompt.security) team and the agent community.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
t39IWpreVBdG2SDMBYrKw3On1UlrimlglhnIiBzvfXTV2gBvxOI815tHsGqfMWsRTvZ6gqbTO1njQy44392pBQ==
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "clawsec-feed",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"description": "Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security",
|
||||
"keywords": [
|
||||
"security",
|
||||
@@ -21,6 +21,11 @@
|
||||
"required": true,
|
||||
"description": "Advisory feed skill documentation"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history for advisory feed updates"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed.json",
|
||||
"required": true,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the ClawSec NanoClaw compatibility skill 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.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
|
||||
|
||||
- Exploitability-aware advisory output in NanoClaw MCP tools (`exploitability_score`, `exploitability_rationale`).
|
||||
- Exploitability filtering (`exploitabilityScore`) for `clawsec_list_advisories`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated NanoClaw advisory sorting and pre-install safety recommendation logic to prioritize exploitability context.
|
||||
- Updated NanoClaw integration docs to match current host/container integration points (`src/ipc.ts`, `src/index.ts`) and current cache schema.
|
||||
- Removed duplicate exploitability normalization logic from MCP advisory tools and now reuse `normalizeExploitabilityScore` from `lib/risk.ts`.
|
||||
- Reused `matchesAffectedSpecifier` from `lib/advisories.ts` in MCP advisory tools to keep skill/version matching logic centralized and consistent.
|
||||
@@ -0,0 +1,327 @@
|
||||
# ClawSec for NanoClaw - Installation Guide
|
||||
|
||||
This guide shows how to add ClawSec security monitoring to your NanoClaw deployment.
|
||||
|
||||
## Overview
|
||||
|
||||
ClawSec provides security advisory monitoring for NanoClaw through:
|
||||
- **MCP Tools**: Agents can check for vulnerabilities via `clawsec_check_advisories`
|
||||
- **Advisory Feed**: Automatic monitoring of https://clawsec.prompt.security/advisories/feed.json
|
||||
- **Signature Verification**: Ed25519-signed feeds ensure integrity
|
||||
- **Exploitability Context**: Advisories include exploitability score and rationale for triage
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- NanoClaw >= 0.1.0
|
||||
- Node.js >= 18.0.0
|
||||
- Write access to NanoClaw installation directory
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Copy Skill Files
|
||||
|
||||
Copy the `clawsec-nanoclaw` skill directory to your NanoClaw installation:
|
||||
|
||||
```bash
|
||||
# From the ClawSec repository
|
||||
cp -r skills/clawsec-nanoclaw /path/to/your/nanoclaw/skills/
|
||||
```
|
||||
|
||||
### 2. Integrate MCP Tools
|
||||
|
||||
Add the ClawSec MCP tools to your NanoClaw container agent runner.
|
||||
|
||||
**File**: `container/agent-runner/src/ipc-mcp-stdio.ts`
|
||||
|
||||
```typescript
|
||||
// Add these imports at the top to register all ClawSec MCP tools:
|
||||
|
||||
// Advisory tools: clawsec_check_advisories, clawsec_check_skill_safety,
|
||||
// clawsec_list_advisories, clawsec_refresh_cache
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js';
|
||||
|
||||
// Signature verification: clawsec_verify_skill_package
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/signature-verification.js';
|
||||
|
||||
// Integrity monitoring: clawsec_check_integrity, clawsec_approve_change,
|
||||
// clawsec_integrity_status, clawsec_verify_audit
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/integrity-tools.js';
|
||||
```
|
||||
|
||||
Each file calls `server.tool()` directly to register its tools. The `server`,
|
||||
`writeIpcFile`, `TASKS_DIR`, and `groupFolder` variables must be available in
|
||||
the scope where these files are imported (they are declared as ambient globals
|
||||
in each tool file).
|
||||
|
||||
### 3. Integrate IPC Handlers
|
||||
|
||||
Add the host-side IPC handlers for ClawSec operations.
|
||||
|
||||
**File**: `src/ipc.ts`
|
||||
|
||||
```typescript
|
||||
// Add these imports at the top
|
||||
import { handleAdvisoryIpc } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
import { SkillSignatureVerifier } from '../skills/clawsec-nanoclaw/host-services/skill-signature-handler.js';
|
||||
|
||||
// Initialize these once in host startup and pass through deps
|
||||
const advisoryCacheManager = new AdvisoryCacheManager('/workspace/project/data', logger);
|
||||
const signatureVerifier = new SkillSignatureVerifier();
|
||||
|
||||
// In processTaskIpc switch:
|
||||
case 'refresh_advisory_cache':
|
||||
case 'verify_skill_signature':
|
||||
await handleAdvisoryIpc(
|
||||
data,
|
||||
{ advisoryCacheManager, signatureVerifier },
|
||||
logger,
|
||||
sourceGroup
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// existing task handling
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Start Advisory Cache Service
|
||||
|
||||
Add the advisory cache manager to your host services.
|
||||
|
||||
**File**: `src/index.ts` (or your main entry point)
|
||||
|
||||
```typescript
|
||||
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
|
||||
// Start the service when your host process starts
|
||||
async function main() {
|
||||
// ... your existing initialization ...
|
||||
|
||||
// Initialize cache manager and prime it at startup
|
||||
const advisoryCacheManager = new AdvisoryCacheManager('/workspace/project/data', logger);
|
||||
await advisoryCacheManager.initialize();
|
||||
|
||||
// Recommended refresh cadence (6h)
|
||||
setInterval(() => {
|
||||
advisoryCacheManager.refresh().catch((error) => {
|
||||
logger.error({ error }, 'Periodic advisory cache refresh failed');
|
||||
});
|
||||
}, 6 * 60 * 60 * 1000);
|
||||
|
||||
// ... rest of your startup ...
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Restart NanoClaw
|
||||
|
||||
Restart your NanoClaw instance to load the new MCP tools and services:
|
||||
|
||||
```bash
|
||||
# Stop NanoClaw
|
||||
docker-compose down
|
||||
|
||||
# Start with new configuration
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Test that ClawSec is working:
|
||||
|
||||
### 1. Check MCP Tools Available
|
||||
|
||||
From within a NanoClaw agent session, the following tools should be available:
|
||||
|
||||
**Advisory Tools** (mcp-tools/advisory-tools.ts):
|
||||
- `clawsec_check_advisories` - Scan installed skills for vulnerabilities
|
||||
- `clawsec_check_skill_safety` - Pre-installation safety check
|
||||
- `clawsec_list_advisories` - List all advisories with filtering
|
||||
- `clawsec_refresh_cache` - Request immediate advisory cache refresh
|
||||
|
||||
**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
|
||||
- `clawsec_approve_change` - Approve intentional file modification as new baseline
|
||||
- `clawsec_integrity_status` - View current baseline status
|
||||
- `clawsec_verify_audit` - Verify audit log hash chain integrity
|
||||
|
||||
### 2. Test Advisory Checking
|
||||
|
||||
Ask your NanoClaw agent:
|
||||
```
|
||||
Check if any of my installed skills have security advisories
|
||||
```
|
||||
|
||||
The agent should use the `clawsec_check_advisories` tool and report results.
|
||||
|
||||
### 3. Check Advisory Cache
|
||||
|
||||
Verify the cache file was created:
|
||||
```bash
|
||||
cat /workspace/project/data/clawsec-advisory-cache.json
|
||||
```
|
||||
|
||||
You should see:
|
||||
- `feed`: Array of advisories
|
||||
- `fetchedAt`: Timestamp of last update
|
||||
- `verified`: Should be `true`
|
||||
- `publicKeyFingerprint`: SHA-256 fingerprint of the pinned signing key
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Agent Commands
|
||||
|
||||
Once installed, your NanoClaw agents can:
|
||||
|
||||
**Check for vulnerabilities:**
|
||||
```
|
||||
Scan my installed skills for security issues
|
||||
```
|
||||
|
||||
**Pre-installation check:**
|
||||
```
|
||||
Is it safe to install skill-name@1.0.0?
|
||||
```
|
||||
|
||||
**List all advisories:**
|
||||
```
|
||||
Show me all ClawSec security advisories
|
||||
```
|
||||
|
||||
### Manual Tool Invocation
|
||||
|
||||
You can also call the MCP tools directly from agent code:
|
||||
|
||||
```typescript
|
||||
// Check all installed skills
|
||||
const result = await tools.clawsec_check_advisories({
|
||||
installRoot: '/home/node/.claude/skills'
|
||||
});
|
||||
|
||||
// Check specific skill before installation
|
||||
const safetyCheck = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'risky-skill',
|
||||
skillVersion: '1.0.0'
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Cache Location
|
||||
|
||||
Default: `/workspace/project/data/clawsec-advisory-cache.json`
|
||||
|
||||
To change, pass a different data directory path to `new AdvisoryCacheManager(dataDir, logger)`.
|
||||
|
||||
### Refresh Interval
|
||||
|
||||
Default: 6 hours
|
||||
|
||||
To change, update the `setInterval(...)` duration (in milliseconds) in host startup.
|
||||
|
||||
### Feed URL
|
||||
|
||||
Default: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
|
||||
To use a mirror or custom feed, update `FEED_URL` in `skills/clawsec-nanoclaw/host-services/advisory-cache.ts`.
|
||||
|
||||
## Platform-Specific Advisories
|
||||
|
||||
ClawSec advisories can target specific platforms:
|
||||
|
||||
- **`platforms: ["nanoclaw"]`**: Only affects NanoClaw
|
||||
- **`platforms: ["openclaw"]`**: Only affects OpenClaw/MoltBot
|
||||
- **`platforms: ["openclaw", "nanoclaw"]`**: Affects both
|
||||
- **No `platforms` field**: Applies to all platforms
|
||||
|
||||
Platform metadata is preserved in advisory records and can be filtered by your policy layer.
|
||||
|
||||
## Security
|
||||
|
||||
### Signature Verification
|
||||
|
||||
All advisory feeds are Ed25519 signed. The public key is pinned in:
|
||||
```
|
||||
skills/clawsec-nanoclaw/advisories/feed-signing-public.pem
|
||||
```
|
||||
|
||||
Feeds failing signature verification are rejected.
|
||||
|
||||
### Cache Integrity
|
||||
|
||||
The advisory cache includes:
|
||||
- Cryptographic signature of feed contents
|
||||
- Verification status
|
||||
- Timestamp of last successful fetch
|
||||
|
||||
Never manually edit the cache file - it will break signature verification.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tools Not Appearing
|
||||
|
||||
**Problem**: MCP tools not showing up in agent
|
||||
|
||||
**Solution**:
|
||||
1. Check that you added the import and registration in `ipc-mcp-stdio.ts`
|
||||
2. Restart the container
|
||||
3. Check container logs for import errors
|
||||
|
||||
### Cache Not Updating
|
||||
|
||||
**Problem**: Advisory cache is empty or stale
|
||||
|
||||
**Solution**:
|
||||
1. Check that `AdvisoryCacheManager.initialize()` is called in your host entry point
|
||||
2. Verify network access to `clawsec.prompt.security`
|
||||
3. Check host logs for fetch errors
|
||||
4. Manually trigger: `curl https://clawsec.prompt.security/advisories/feed.json`
|
||||
|
||||
### Signature Verification Failing
|
||||
|
||||
**Problem**: Cache shows `"verified": false`
|
||||
|
||||
**Solution**:
|
||||
1. Ensure public key file exists at correct path
|
||||
2. Check file permissions (should be readable)
|
||||
3. Verify feed URL is correct (not using HTTP instead of HTTPS)
|
||||
4. Check for corrupted downloads (try clearing cache and refetching)
|
||||
|
||||
### IPC Communication Issues
|
||||
|
||||
**Problem**: Tools return errors about IPC
|
||||
|
||||
**Solution**:
|
||||
1. Verify IPC handlers are registered in `src/ipc.ts`
|
||||
2. Check that IPC directory exists and is writable
|
||||
3. Ensure host process is running
|
||||
4. Check host logs for handler errors
|
||||
|
||||
## Uninstallation
|
||||
|
||||
To remove ClawSec from NanoClaw:
|
||||
|
||||
1. Remove MCP tool registration from `ipc-mcp-stdio.ts`
|
||||
2. Remove IPC handler registration from `src/ipc.ts`
|
||||
3. Remove `AdvisoryCacheManager` initialization from host entry point
|
||||
4. Delete the skill directory: `rm -rf skills/clawsec-nanoclaw`
|
||||
5. Delete the cache file: `rm /workspace/project/data/clawsec-advisory-cache.json`
|
||||
6. Restart NanoClaw
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: https://clawsec.prompt.security/
|
||||
- **Issues**: https://github.com/prompt-security/clawsec/issues
|
||||
- **Security**: security@prompt.security
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-or-later
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Open an issue or check the main ClawSec documentation.
|
||||
@@ -0,0 +1,151 @@
|
||||
# ClawSec for NanoClaw
|
||||
|
||||
ClawSec now supports NanoClaw, a containerized WhatsApp bot powered by Claude agents.
|
||||
|
||||
## What Changed
|
||||
|
||||
### Advisory Feed Monitoring
|
||||
- **NVD CVE Pipeline**: Now monitors for NanoClaw-specific keywords
|
||||
- "NanoClaw", "WhatsApp-bot", "baileys" (WhatsApp library)
|
||||
- Container-related vulnerabilities
|
||||
- **Platform Targeting**: Advisories can specify `platforms: ["nanoclaw"]` for NanoClaw-specific issues
|
||||
|
||||
### Keywords Added
|
||||
The CVE monitoring now includes:
|
||||
- `NanoClaw` - Direct product name
|
||||
- `WhatsApp-bot` - Core functionality
|
||||
- `baileys` - WhatsApp client library dependency
|
||||
|
||||
## Advisory Schema
|
||||
|
||||
Advisories now support optional `platforms` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "CVE-2026-XXXXX",
|
||||
"platforms": ["openclaw", "nanoclaw"],
|
||||
"severity": "critical",
|
||||
"type": "prompt_injection",
|
||||
"affected": ["skill-name@1.0.0"],
|
||||
"action": "Update to version 1.0.1"
|
||||
}
|
||||
```
|
||||
|
||||
**Platform values:**
|
||||
- `"openclaw"` - Affects OpenClaw/ClawdBot/MoltBot only
|
||||
- `"nanoclaw"` - Affects NanoClaw only
|
||||
- `["openclaw", "nanoclaw"]` - Affects both platforms
|
||||
- (empty/missing) - Applies to all platforms (backward compatible)
|
||||
|
||||
## ClawSec NanoClaw Skill
|
||||
|
||||
ClawSec provides a complete security skill for NanoClaw deployments:
|
||||
|
||||
**Location**: `skills/clawsec-nanoclaw/`
|
||||
|
||||
### Features
|
||||
|
||||
- **9 MCP Tools** for agents to manage security:
|
||||
- `clawsec_check_advisories` - Scan installed skills for vulnerabilities
|
||||
- `clawsec_check_skill_safety` - Pre-installation safety checks
|
||||
- `clawsec_list_advisories` - Browse advisory feed with filtering
|
||||
- `clawsec_refresh_cache` - Request immediate advisory cache refresh
|
||||
- `clawsec_verify_skill_package` - Verify Ed25519 signatures on skill packages
|
||||
- `clawsec_check_integrity` - Check protected files for unauthorized changes
|
||||
- `clawsec_approve_change` - Approve intentional file modifications
|
||||
- `clawsec_integrity_status` - View file baseline status
|
||||
- `clawsec_verify_audit` - Verify audit log hash chain
|
||||
|
||||
- **Advisory Cache Service**: Host-managed feed fetching with signature validation
|
||||
- **Signature Verification**: Ed25519-signed feeds ensure integrity
|
||||
- **Exploitability Context**: Surfaces `exploitability_score` and rationale to reduce alert fatigue
|
||||
- **IPC Communication**: Container-safe host communication
|
||||
|
||||
### Installation
|
||||
|
||||
1. Copy the skill to your NanoClaw deployment:
|
||||
```bash
|
||||
cp -r skills/clawsec-nanoclaw /path/to/nanoclaw/skills/
|
||||
```
|
||||
|
||||
2. Follow the detailed guide at `skills/clawsec-nanoclaw/INSTALL.md`
|
||||
|
||||
### Quick Integration
|
||||
|
||||
The skill integrates into three places:
|
||||
|
||||
**1. MCP Tools** (container):
|
||||
```typescript
|
||||
// container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js';
|
||||
```
|
||||
|
||||
**2. IPC Handlers** (host):
|
||||
```typescript
|
||||
// src/ipc.ts
|
||||
import { handleAdvisoryIpc } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
```
|
||||
|
||||
**3. Cache Service** (host):
|
||||
```typescript
|
||||
// src/index.ts
|
||||
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
```
|
||||
|
||||
### Advisory Feed
|
||||
|
||||
NanoClaw consumes the same feed as OpenClaw:
|
||||
```
|
||||
https://clawsec.prompt.security/advisories/feed.json
|
||||
```
|
||||
|
||||
The feed is Ed25519 signed and automatically fetched by the cache service.
|
||||
|
||||
## Team Credits
|
||||
|
||||
This integration was developed by a team of 8 specialized agents coordinated to adapt ClawSec for NanoClaw:
|
||||
|
||||
- **pioneer-repo-scout** - ClawSec architecture analysis
|
||||
- **pioneer-nanoclaw-scout** - NanoClaw architecture analysis
|
||||
- **architect** - Integration design and coordination
|
||||
- **advisory-specialist** - Advisory feed integration
|
||||
- **integrity-specialist** - File integrity design
|
||||
- **installer-specialist** - Signature verification implementation
|
||||
- **tester** - Test infrastructure and validation
|
||||
- **documenter** - Documentation
|
||||
|
||||
Total contribution: 3000+ lines of code and comprehensive design documents.
|
||||
|
||||
## What's Included
|
||||
|
||||
The `clawsec-nanoclaw` skill provides:
|
||||
|
||||
- **1,730 lines** of production-ready TypeScript code
|
||||
- **MCP Tools** (350 lines): Agent-facing vulnerability checking
|
||||
- **Advisory Cache** (492 lines): Automatic feed fetching and caching
|
||||
- **Signature Verification** (387 lines): Ed25519 signature validation
|
||||
- **Advisory Matching** (289 lines): Skill-to-vulnerability correlation
|
||||
- **IPC Handlers** (212 lines): Container-to-host communication
|
||||
- **Complete Documentation**: Installation guide, usage examples, troubleshooting
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned features for future releases:
|
||||
- File integrity monitoring (soul-guardian adaptation for containers)
|
||||
- Real-time advisory alerts via WebSocket
|
||||
- WhatsApp-native security alert formatting
|
||||
- Behavioral analysis and anomaly detection
|
||||
- Custom/private advisory feed support
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Skill Documentation](skills/clawsec-nanoclaw/SKILL.md) - Features and architecture
|
||||
- [Installation Guide](skills/clawsec-nanoclaw/INSTALL.md) - Detailed setup instructions
|
||||
- [ClawSec Main README](README.md) - Overall ClawSec documentation
|
||||
- [Security & Signing](../../wiki/security-signing-runbook.md) - Signature verification details
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: https://github.com/prompt-security/clawsec/issues
|
||||
- **Security**: security@prompt.security
|
||||
- NanoClaw Repository: https://github.com/qwibitai/nanoclaw
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
name: clawsec-nanoclaw
|
||||
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
|
||||
---
|
||||
|
||||
# ClawSec for NanoClaw
|
||||
|
||||
Security advisory monitoring that protects your WhatsApp bot from known vulnerabilities in skills and dependencies.
|
||||
|
||||
## Overview
|
||||
|
||||
ClawSec provides MCP tools that check installed skills against a curated feed of security advisories. It prevents installation of vulnerable skills, includes exploitability context for triage, and alerts you to issues in existing ones.
|
||||
|
||||
**Core principle:** Check before you install. Monitor what's running.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use ClawSec tools when:
|
||||
- Installing a new skill (check safety first)
|
||||
- User asks "are my skills secure?"
|
||||
- Investigating suspicious behavior
|
||||
- Regular security audits
|
||||
- After receiving security notifications
|
||||
|
||||
Do NOT use for:
|
||||
- Code review (use other tools)
|
||||
- Performance issues (different concern)
|
||||
- General debugging
|
||||
|
||||
## MCP Tools Available
|
||||
|
||||
### Pre-Installation Check
|
||||
|
||||
```typescript
|
||||
// Before installing any skill
|
||||
const safety = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'new-skill',
|
||||
skillVersion: '1.0.0' // optional
|
||||
});
|
||||
|
||||
if (!safety.safe) {
|
||||
// Show user the risks before proceeding
|
||||
console.warn(`Security issues: ${safety.advisories.map(a => a.id)}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Security Audit
|
||||
|
||||
```typescript
|
||||
// Check all installed skills (defaults to ~/.claude/skills in the container)
|
||||
const result = await tools.clawsec_check_advisories({
|
||||
installRoot: '/home/node/.claude/skills' // optional
|
||||
});
|
||||
|
||||
if (result.matches.some((m) =>
|
||||
m.advisory.severity === 'critical' || m.advisory.exploitability_score === 'high'
|
||||
)) {
|
||||
// Alert user immediately
|
||||
console.error('Urgent advisories found!');
|
||||
}
|
||||
```
|
||||
|
||||
### Browse Advisories
|
||||
|
||||
```typescript
|
||||
// List advisories with filters
|
||||
const advisories = await tools.clawsec_list_advisories({
|
||||
severity: 'high', // optional
|
||||
exploitabilityScore: 'high' // optional
|
||||
});
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Tool | Key Parameter |
|
||||
|------|------|---------------|
|
||||
| Pre-install check | `clawsec_check_skill_safety` | `skillName` |
|
||||
| Audit all skills | `clawsec_check_advisories` | `installRoot` (optional) |
|
||||
| Browse feed | `clawsec_list_advisories` | `severity`, `type`, `exploitabilityScore` (optional) |
|
||||
| Verify package signature | `clawsec_verify_skill_package` | `packagePath` |
|
||||
| Refresh advisory cache | `clawsec_refresh_cache` | (none) |
|
||||
| Check file integrity | `clawsec_check_integrity` | `mode`, `autoRestore` (optional) |
|
||||
| Approve file change | `clawsec_approve_change` | `path` |
|
||||
| View baseline status | `clawsec_integrity_status` | `path` (optional) |
|
||||
| Verify audit log | `clawsec_verify_audit` | (none) |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Safe Skill Installation
|
||||
|
||||
```typescript
|
||||
// ALWAYS check before installing
|
||||
const safety = await tools.clawsec_check_skill_safety({
|
||||
skillName: userRequestedSkill
|
||||
});
|
||||
|
||||
if (safety.safe) {
|
||||
// Proceed with installation
|
||||
await installSkill(userRequestedSkill);
|
||||
} else {
|
||||
// Show user the risks and get confirmation
|
||||
await showSecurityWarning(safety.advisories);
|
||||
if (await getUserConfirmation()) {
|
||||
await installSkill(userRequestedSkill);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Periodic Security Check
|
||||
|
||||
```typescript
|
||||
// Add to scheduled tasks
|
||||
schedule_task({
|
||||
prompt: "Check advisories using clawsec_check_advisories and alert when critical or high-exploitability matches appear",
|
||||
schedule_type: "cron",
|
||||
schedule_value: "0 9 * * *" // Daily at 9am
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 3: User Security Query
|
||||
|
||||
```
|
||||
User: "Are my skills secure?"
|
||||
|
||||
You: I'll check installed skills for known vulnerabilities.
|
||||
[Use clawsec_check_advisories]
|
||||
|
||||
Response:
|
||||
✅ No urgent issues found.
|
||||
- 2 low-severity/low-exploitability advisories
|
||||
- All skills up to date
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Installing without checking
|
||||
```typescript
|
||||
// DON'T
|
||||
await installSkill('untrusted-skill');
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO
|
||||
const safety = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'untrusted-skill'
|
||||
});
|
||||
if (safety.safe) await installSkill('untrusted-skill');
|
||||
```
|
||||
|
||||
### ❌ Ignoring exploitability context
|
||||
```typescript
|
||||
// DON'T: Use severity only
|
||||
if (advisory.severity === 'high') {
|
||||
notifyNow(advisory);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO: Use exploitability + severity
|
||||
if (
|
||||
advisory.exploitability_score === 'high' ||
|
||||
advisory.severity === 'critical'
|
||||
) {
|
||||
notifyNow(advisory);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Skipping critical severity
|
||||
```typescript
|
||||
// DON'T: Ignore high exploitability in medium severity advisories
|
||||
if (advisory.severity === 'critical') alert();
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO: Prioritize exploitability and severity together
|
||||
if (advisory.exploitability_score === 'high' || advisory.severity === 'critical') {
|
||||
// Alert immediately
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Feed Source**: https://clawsec.prompt.security/advisories/feed.json
|
||||
|
||||
**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`
|
||||
|
||||
See [INSTALL.md](./INSTALL.md) for setup and [docs/](./docs/) for advanced usage.
|
||||
|
||||
## Real-World Impact
|
||||
|
||||
- Prevents installation of skills with known RCE vulnerabilities
|
||||
- Alerts to supply chain attacks in dependencies
|
||||
- Provides actionable remediation steps
|
||||
- Zero false positives (curated feed only)
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,567 @@
|
||||
# File Integrity Monitoring for NanoClaw
|
||||
|
||||
ClawSec's file integrity monitoring protects critical NanoClaw configuration files from unauthorized modification.
|
||||
|
||||
## What It Does
|
||||
|
||||
**Protects Critical Files:**
|
||||
- `registered_groups.json` - Prevents unauthorized group access
|
||||
- `CLAUDE.md` files - Protects agent instructions
|
||||
- Container/host code - Alerts on unexpected changes
|
||||
|
||||
**How It Works:**
|
||||
1. **Baseline**: Stores SHA-256 hashes of approved file states
|
||||
2. **Monitoring**: Periodically checks files for changes (drift)
|
||||
3. **Restore**: Automatically reverts critical files to approved versions
|
||||
4. **Audit**: Maintains tamper-evident log of all operations
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Verify Installation
|
||||
|
||||
Check that integrity monitoring is available:
|
||||
|
||||
```bash
|
||||
# From container
|
||||
ls /workspace/project/skills/clawsec-nanoclaw/guardian/
|
||||
# Should show: policy.json, integrity-monitor.ts
|
||||
```
|
||||
|
||||
### Step 2: Initialize Baselines
|
||||
|
||||
The first time integrity monitoring runs, it creates baselines automatically:
|
||||
|
||||
```typescript
|
||||
// Agent calls this (happens automatically on first integrity check)
|
||||
await tools.clawsec_check_integrity();
|
||||
```
|
||||
|
||||
This creates:
|
||||
```
|
||||
/workspace/project/data/soul-guardian/
|
||||
├── baselines.json # SHA-256 hashes
|
||||
├── approved/ # File snapshots
|
||||
│ ├── registered_groups.json
|
||||
│ └── CLAUDE.md
|
||||
├── patches/ # Diffs (empty initially)
|
||||
├── quarantine/ # Tampered files (empty initially)
|
||||
└── audit.jsonl # Event log
|
||||
```
|
||||
|
||||
### Step 3: Enable Scheduled Monitoring
|
||||
|
||||
Add to main group's scheduled tasks:
|
||||
|
||||
```typescript
|
||||
schedule_task({
|
||||
prompt: `
|
||||
Check file integrity with clawsec_check_integrity.
|
||||
If drift detected and files restored, send WhatsApp message:
|
||||
"⚠️ SECURITY ALERT
|
||||
|
||||
Unauthorized changes detected and automatically reverted:
|
||||
[list files that were restored]
|
||||
|
||||
Review details: /workspace/project/data/soul-guardian/patches/"
|
||||
`,
|
||||
schedule_type: 'cron',
|
||||
schedule_value: '*/30 * * * *', // Every 30 minutes
|
||||
context_mode: 'isolated'
|
||||
});
|
||||
```
|
||||
|
||||
That's it! Integrity monitoring is now active.
|
||||
|
||||
## MCP Tools Reference
|
||||
|
||||
### 1. `clawsec_check_integrity`
|
||||
|
||||
Check all protected files for unauthorized changes.
|
||||
|
||||
**Parameters:**
|
||||
- `mode` (optional): `'check'` (default) or `'status'`
|
||||
- `check`: Detect drift and auto-restore
|
||||
- `status`: View baselines only (no drift detection)
|
||||
- `autoRestore` (optional): `true` (default) or `false`
|
||||
- If `false`, drift is detected but not auto-fixed
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"timestamp": "2026-02-25T12:00:00Z",
|
||||
"drift_detected": false,
|
||||
"files": [
|
||||
{
|
||||
"path": "/workspace/project/data/registered_groups.json",
|
||||
"status": "ok",
|
||||
"mode": "restore",
|
||||
"expected_sha": "abc123...",
|
||||
"found_sha": "abc123..."
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"ok": 3,
|
||||
"drifted": 0,
|
||||
"restored": 0,
|
||||
"alerted": 0,
|
||||
"errors": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const result = await tools.clawsec_check_integrity();
|
||||
|
||||
if (result.drift_detected) {
|
||||
console.log('⚠️ Drift detected!');
|
||||
for (const file of result.files) {
|
||||
if (file.status === 'restored') {
|
||||
console.log(`✅ Restored: ${file.path}`);
|
||||
console.log(` Diff: ${file.patch_path}`);
|
||||
} else if (file.status === 'drifted') {
|
||||
console.log(`⚠️ Changed: ${file.path} (alert only)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `clawsec_approve_change`
|
||||
|
||||
Approve an intentional file modification as the new baseline.
|
||||
|
||||
**When to use:**
|
||||
- After legitimately updating CLAUDE.md
|
||||
- After adding/removing groups in registered_groups.json
|
||||
- After any intentional change to protected files
|
||||
|
||||
**Parameters:**
|
||||
- `path` (required): Absolute path to file
|
||||
- `note` (optional): Explanation for audit log
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"path": "/workspace/group/CLAUDE.md",
|
||||
"approved_at": "2026-02-25T12:00:00Z",
|
||||
"approved_by": "agent",
|
||||
"note": "Added new skill instructions"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// After editing CLAUDE.md
|
||||
await tools.clawsec_approve_change({
|
||||
path: '/workspace/group/CLAUDE.md',
|
||||
note: 'Updated agent instructions for new skill'
|
||||
});
|
||||
|
||||
console.log('✅ Change approved - new baseline created');
|
||||
```
|
||||
|
||||
### 3. `clawsec_integrity_status`
|
||||
|
||||
View current baseline status without checking for drift.
|
||||
|
||||
**Parameters:**
|
||||
- `path` (optional): Specific file, or all if omitted
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"baseline_age": "2026-02-25T10:00:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "/workspace/project/data/registered_groups.json",
|
||||
"mode": "restore",
|
||||
"priority": "critical",
|
||||
"has_baseline": true,
|
||||
"baseline_sha": "abc123...",
|
||||
"approved_at": "2026-02-25T10:00:00Z",
|
||||
"snapshot_exists": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const status = await tools.clawsec_integrity_status();
|
||||
|
||||
console.log('Protected files:');
|
||||
for (const file of status.files) {
|
||||
console.log(`- ${file.path} (${file.mode}, ${file.priority})`);
|
||||
console.log(` Last approved: ${file.approved_at}`);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `clawsec_verify_audit`
|
||||
|
||||
Verify audit log hash chain integrity.
|
||||
|
||||
**No parameters.**
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"valid": true,
|
||||
"entries": 42,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const verification = await tools.clawsec_verify_audit();
|
||||
|
||||
if (!verification.valid) {
|
||||
console.log('🚨 CRITICAL: Audit log has been tampered with!');
|
||||
console.log('Errors:', verification.errors);
|
||||
} else {
|
||||
console.log(`✅ Audit log verified (${verification.entries} entries)`);
|
||||
}
|
||||
```
|
||||
|
||||
## Protected Files Policy
|
||||
|
||||
### Critical Priority (Auto-Restore)
|
||||
|
||||
**`/workspace/project/data/registered_groups.json`**
|
||||
- **Risk**: Tampering grants unauthorized group access
|
||||
- **Action**: Immediate auto-restore + alert
|
||||
|
||||
**`/workspace/group/CLAUDE.md`**
|
||||
- **Risk**: Modifies agent behavior
|
||||
- **Action**: Immediate auto-restore + alert
|
||||
|
||||
**`/workspace/project/groups/global/CLAUDE.md`**
|
||||
- **Risk**: Affects all groups
|
||||
- **Action**: Immediate auto-restore + alert
|
||||
|
||||
### Medium Priority (Alert Only)
|
||||
|
||||
**Container code** (`/workspace/project/container/**/*.ts`)
|
||||
- **Risk**: Unexpected code changes
|
||||
- **Action**: Alert for review (no auto-restore)
|
||||
|
||||
**Host code** (`/workspace/project/host/**/*.ts`)
|
||||
- **Risk**: Unexpected code changes
|
||||
- **Action**: Alert for review (no auto-restore)
|
||||
|
||||
### Ignored
|
||||
|
||||
**IPC files** (`/workspace/ipc/**/*`)
|
||||
- Changes are expected and frequent
|
||||
|
||||
**Conversations** (`/workspace/group/conversations/**/*`)
|
||||
- Changes are expected and frequent
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Scenario 1: Scheduled Monitoring
|
||||
|
||||
**Setup:**
|
||||
```typescript
|
||||
schedule_task({
|
||||
prompt: 'Run clawsec_check_integrity and alert on drift',
|
||||
schedule_type: 'cron',
|
||||
schedule_value: '*/30 * * * *'
|
||||
});
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Every 30 minutes, agent checks integrity
|
||||
2. If drift detected in critical files:
|
||||
- Files auto-restored to baseline
|
||||
- Tampered versions quarantined
|
||||
- Diff patch generated
|
||||
- User alerted via WhatsApp
|
||||
3. If drift in non-critical files:
|
||||
- Alert only, no auto-restore
|
||||
|
||||
### Scenario 2: Updating Agent Instructions
|
||||
|
||||
**Workflow:**
|
||||
```typescript
|
||||
// 1. Edit CLAUDE.md
|
||||
fs.writeFileSync('/workspace/group/CLAUDE.md', newInstructions);
|
||||
|
||||
// 2. Test changes
|
||||
// ... verify agent behaves correctly ...
|
||||
|
||||
// 3. Approve changes
|
||||
await tools.clawsec_approve_change({
|
||||
path: '/workspace/group/CLAUDE.md',
|
||||
note: 'Added instructions for new weather skill'
|
||||
});
|
||||
|
||||
// 4. Future integrity checks will use this new baseline
|
||||
```
|
||||
|
||||
### Scenario 3: Adding a New Group
|
||||
|
||||
**Workflow:**
|
||||
```typescript
|
||||
// 1. Add group to registered_groups.json
|
||||
const groups = JSON.parse(fs.readFileSync('/workspace/project/data/registered_groups.json'));
|
||||
groups['new-jid'] = { name: 'Family', folder: 'family', trigger: '@Andy' };
|
||||
fs.writeFileSync('/workspace/project/data/registered_groups.json', JSON.stringify(groups, null, 2));
|
||||
|
||||
// 2. Approve the change
|
||||
await tools.clawsec_approve_change({
|
||||
path: '/workspace/project/data/registered_groups.json',
|
||||
note: 'Added family group'
|
||||
});
|
||||
```
|
||||
|
||||
### Scenario 4: Investigating Drift
|
||||
|
||||
**When drift is detected:**
|
||||
```typescript
|
||||
const result = await tools.clawsec_check_integrity();
|
||||
|
||||
if (result.drift_detected) {
|
||||
for (const file of result.files) {
|
||||
if (file.status === 'restored') {
|
||||
// Critical file was auto-restored
|
||||
console.log(`🔧 Auto-restored: ${file.path}`);
|
||||
console.log(`📄 Diff: ${file.patch_path}`);
|
||||
console.log(`📦 Quarantine: ${file.quarantine_path}`);
|
||||
|
||||
// Review the diff
|
||||
const diff = fs.readFileSync(file.patch_path, 'utf-8');
|
||||
console.log('Changes that were reverted:');
|
||||
console.log(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
### Threat Model
|
||||
|
||||
**Protects Against:**
|
||||
- Unauthorized file modifications
|
||||
- Group hijacking (via registered_groups.json tampering)
|
||||
- Agent instruction poisoning (via CLAUDE.md changes)
|
||||
- Accidental file corruption
|
||||
|
||||
**Does NOT Protect Against:**
|
||||
- Attacker with full host access (can modify baselines)
|
||||
- Simultaneous baseline + file modification
|
||||
- Malicious scheduled tasks that approve their own changes
|
||||
|
||||
### Baseline Storage
|
||||
|
||||
**Location:** `/workspace/project/data/soul-guardian/`
|
||||
|
||||
**Access Control:**
|
||||
- Baselines written only by host process
|
||||
- Containers access via IPC only
|
||||
- No container can modify its own baselines
|
||||
|
||||
**Integrity:**
|
||||
- SHA-256 hashes (industry standard)
|
||||
- Hash-chained audit log (tamper-evident)
|
||||
- Atomic file operations (safe restores)
|
||||
|
||||
### Audit Log
|
||||
|
||||
**Format:** JSONL with hash chaining
|
||||
|
||||
**Each entry includes:**
|
||||
```json
|
||||
{
|
||||
"ts": "2026-02-25T12:00:00Z",
|
||||
"event": "drift",
|
||||
"actor": "agent",
|
||||
"path": "/workspace/group/CLAUDE.md",
|
||||
"expected_sha": "abc123...",
|
||||
"found_sha": "def456...",
|
||||
"chain": {
|
||||
"prev": "previous_entry_hash",
|
||||
"hash": "this_entry_hash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Chain calculation:**
|
||||
```
|
||||
hash = SHA-256(prev_hash + '\n' + canonical_json(entry_without_chain))
|
||||
```
|
||||
|
||||
This makes tampering detectable: changing any entry breaks the chain.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Integrity Check Fails
|
||||
|
||||
**Symptom:** `clawsec_check_integrity` returns `success: false`
|
||||
|
||||
**Causes:**
|
||||
1. IntegrityService not initialized
|
||||
2. Policy file missing
|
||||
3. Baselines corrupted
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check service status
|
||||
ls /workspace/project/data/soul-guardian/
|
||||
|
||||
# If missing, reinitialize
|
||||
rm -rf /workspace/project/data/soul-guardian/
|
||||
# Next integrity check will recreate baselines
|
||||
```
|
||||
|
||||
### False Positives (Legitimate Changes Flagged)
|
||||
|
||||
**Symptom:** File keeps getting restored even though changes are legitimate
|
||||
|
||||
**Cause:** Baseline not updated after intentional changes
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
await tools.clawsec_approve_change({
|
||||
path: '/path/to/file',
|
||||
note: 'Legitimate change'
|
||||
});
|
||||
```
|
||||
|
||||
### Audit Chain Broken
|
||||
|
||||
**Symptom:** `clawsec_verify_audit` returns `valid: false`
|
||||
|
||||
**Causes:**
|
||||
1. Audit log manually edited
|
||||
2. Filesystem corruption
|
||||
3. Security breach
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
const verification = await tools.clawsec_verify_audit();
|
||||
console.log('Errors:', verification.errors);
|
||||
|
||||
// If corruption, backup and reset
|
||||
cp /workspace/project/data/soul-guardian/audit.jsonl /tmp/audit-backup.jsonl
|
||||
rm /workspace/project/data/soul-guardian/audit.jsonl
|
||||
// Audit log will restart on next operation
|
||||
```
|
||||
|
||||
### High Disk Usage
|
||||
|
||||
**Symptom:** `/workspace/project/data/soul-guardian/` grows large
|
||||
|
||||
**Causes:**
|
||||
- Many drift events generate patches
|
||||
- Quarantine files accumulate
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Clean old patches (older than 30 days)
|
||||
find /workspace/project/data/soul-guardian/patches/ -mtime +30 -delete
|
||||
|
||||
# Clean quarantine (after review)
|
||||
rm /workspace/project/data/soul-guardian/quarantine/*
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
**Overhead:**
|
||||
- Baseline check: ~10ms per file
|
||||
- SHA-256 computation: ~1ms per KB
|
||||
- Restore operation: ~20ms per file
|
||||
|
||||
**Typical deployment:**
|
||||
- 3-5 protected files
|
||||
- 30-minute check interval
|
||||
- < 0.1% CPU usage
|
||||
- < 5MB disk usage
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Custom Policy
|
||||
|
||||
While the default policy is pinned by the skill, you can fork it:
|
||||
|
||||
```bash
|
||||
cp /workspace/project/skills/clawsec-nanoclaw/guardian/policy.json /workspace/project/data/custom-policy.json
|
||||
```
|
||||
|
||||
Edit and reinitialize:
|
||||
```typescript
|
||||
// Update IntegrityMonitor initialization
|
||||
new IntegrityMonitor({
|
||||
policyPath: '/workspace/project/data/custom-policy.json',
|
||||
stateDir: '/workspace/project/data/soul-guardian'
|
||||
});
|
||||
```
|
||||
|
||||
### Manual Baseline Export
|
||||
|
||||
```bash
|
||||
# Export current baselines
|
||||
cp /workspace/project/data/soul-guardian/baselines.json /tmp/baselines-backup.json
|
||||
|
||||
# Export approved snapshots
|
||||
tar -czf /tmp/approved-snapshots.tar.gz /workspace/project/data/soul-guardian/approved/
|
||||
```
|
||||
|
||||
### Baseline Import (Disaster Recovery)
|
||||
|
||||
```bash
|
||||
# Restore baselines
|
||||
cp /tmp/baselines-backup.json /workspace/project/data/soul-guardian/baselines.json
|
||||
|
||||
# Restore snapshots
|
||||
tar -xzf /tmp/approved-snapshots.tar.gz -C /workspace/project/data/soul-guardian/
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I disable auto-restore for testing?**
|
||||
|
||||
A: Yes, use `autoRestore: false`:
|
||||
```typescript
|
||||
await tools.clawsec_check_integrity({ autoRestore: false });
|
||||
```
|
||||
|
||||
**Q: How do I protect additional files?**
|
||||
|
||||
A: Edit `policy.json` and add targets:
|
||||
```json
|
||||
{
|
||||
"path": "/workspace/group/my-config.json",
|
||||
"mode": "restore",
|
||||
"priority": "high",
|
||||
"description": "My custom config"
|
||||
}
|
||||
```
|
||||
|
||||
**Q: What happens if both baseline and file are modified?**
|
||||
|
||||
A: The most recent baseline wins. Always approve legitimate changes immediately.
|
||||
|
||||
**Q: Can I run integrity checks on-demand?**
|
||||
|
||||
A: Yes, just call `clawsec_check_integrity` from any agent.
|
||||
|
||||
**Q: Is the audit log encrypted?**
|
||||
|
||||
A: No, but it's hash-chained for tamper detection. Encryption can be added in Phase 3.
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: https://clawsec.prompt.security/
|
||||
- **Issues**: https://github.com/prompt-security/clawsec/issues
|
||||
- **Security Reports**: security@prompt.security
|
||||
|
||||
---
|
||||
|
||||
**Ready to protect your NanoClaw deployment? Start with the [Quick Start](#quick-start) guide above.**
|
||||
@@ -0,0 +1,488 @@
|
||||
# Skill Package Signing and Verification
|
||||
|
||||
This document explains how ClawSec signs skill packages and how NanoClaw agents verify signatures before installation.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [For Skill Publishers: How to Sign Packages](#for-skill-publishers-how-to-sign-packages)
|
||||
3. [For NanoClaw Agents: How to Verify Signatures](#for-nanoclaw-agents-how-to-verify-signatures)
|
||||
4. [Security Properties](#security-properties)
|
||||
5. [Key Management](#key-management)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Skill signature verification prevents **supply chain attacks** by ensuring skill packages haven't been tampered with during distribution. ClawSec uses **Ed25519 digital signatures** to sign skill packages, and NanoClaw agents verify these signatures before installation.
|
||||
|
||||
### Why Signature Verification?
|
||||
|
||||
Without signature verification, an attacker could:
|
||||
- **Replace** a legitimate skill package with a malicious one during download
|
||||
- **Modify** package contents to inject backdoors or steal data
|
||||
- **Distribute** trojan skills that appear legitimate but contain malware
|
||||
|
||||
Signature verification ensures:
|
||||
- ✅ **Authenticity**: Package comes from ClawSec (or trusted publisher)
|
||||
- ✅ **Integrity**: Package hasn't been modified since signing
|
||||
- ✅ **Non-repudiation**: Signer can't deny signing the package
|
||||
|
||||
---
|
||||
|
||||
## For Skill Publishers: How to Sign Packages
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- OpenSSL 1.1.1+ (for Ed25519 support)
|
||||
- Private Ed25519 signing key (generate once, keep secure)
|
||||
- Skill package ready for distribution
|
||||
|
||||
### Step 1: Generate Ed25519 Keypair (One-Time Setup)
|
||||
|
||||
```bash
|
||||
# Generate private key (KEEP THIS SECRET!)
|
||||
openssl genpkey -algorithm ED25519 -out clawsec-signing-private.pem
|
||||
|
||||
# Extract public key (share this with users)
|
||||
openssl pkey -in clawsec-signing-private.pem -pubout -out clawsec-signing-public.pem
|
||||
|
||||
# Secure the private key
|
||||
chmod 600 clawsec-signing-private.pem
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL**: Never commit the private key to version control! Store it securely:
|
||||
- Local machine: `~/.ssh/clawsec-signing-private.pem` with `chmod 600`
|
||||
- CI/CD: GitHub Secrets, AWS Secrets Manager, or similar
|
||||
- Team: 1Password, Vault, or hardware security module (HSM)
|
||||
|
||||
### Step 2: Package Your Skill
|
||||
|
||||
```bash
|
||||
# Create skill package (tarball or zip)
|
||||
tar -czf my-skill-1.0.0.tar.gz -C skills/my-skill .
|
||||
|
||||
# Or as a zip file
|
||||
zip -r my-skill-1.0.0.zip skills/my-skill/
|
||||
```
|
||||
|
||||
### Step 3: Sign the Package
|
||||
|
||||
```bash
|
||||
# Create detached Ed25519 signature
|
||||
openssl dgst -sha512 -sign clawsec-signing-private.pem \
|
||||
-out my-skill-1.0.0.tar.gz.sig \
|
||||
my-skill-1.0.0.tar.gz
|
||||
|
||||
# Verify the signature was created
|
||||
ls -lh my-skill-1.0.0.tar.gz.sig
|
||||
# Should show a ~64-byte file
|
||||
```
|
||||
|
||||
**Signature Format**: Detached Ed25519 signature, base64-encoded, stored in `.sig` file.
|
||||
|
||||
### Step 4: Distribute Package + Signature
|
||||
|
||||
Distribute **both** files together:
|
||||
- `my-skill-1.0.0.tar.gz` (the skill package)
|
||||
- `my-skill-1.0.0.tar.gz.sig` (the signature)
|
||||
|
||||
Users will verify the signature against your public key before installation.
|
||||
|
||||
### Step 5: Publish Public Key
|
||||
|
||||
Share your public key with users via:
|
||||
- **Pinned in repository**: Commit `clawsec-signing-public.pem` to your repo
|
||||
- **Website**: Host at `https://yoursite.com/clawsec-signing-public.pem`
|
||||
- **DNS TXT record**: Publish as base64-encoded TXT record
|
||||
- **Skill metadata**: Embed in `skill.json`
|
||||
|
||||
---
|
||||
|
||||
## For NanoClaw Agents: How to Verify Signatures
|
||||
|
||||
### Quick Start
|
||||
|
||||
```typescript
|
||||
// Verify a downloaded skill package before installation
|
||||
const verification = await tools.clawsec_verify_skill_package({
|
||||
packagePath: '/tmp/my-skill-1.0.0.tar.gz'
|
||||
// signaturePath auto-detected as /tmp/my-skill-1.0.0.tar.gz.sig
|
||||
});
|
||||
|
||||
const result = JSON.parse(verification.content[0].text);
|
||||
|
||||
if (!result.valid) {
|
||||
console.log('⚠️ SIGNATURE VERIFICATION FAILED!');
|
||||
console.log(`Reason: ${result.reason || result.error}`);
|
||||
console.log('DO NOT install this package.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✓ Signature valid (signer: ${result.signer})`);
|
||||
console.log(`Package hash: ${result.packageInfo.sha256}`);
|
||||
console.log('Safe to proceed with installation.');
|
||||
```
|
||||
|
||||
### MCP Tool: `clawsec_verify_skill_package`
|
||||
|
||||
**Parameters:**
|
||||
- `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"
|
||||
algorithm: "Ed25519", // Signature algorithm
|
||||
verifiedAt: string, // ISO timestamp
|
||||
packageInfo: {
|
||||
size: number, // Package file size in bytes
|
||||
sha256: string // SHA-256 hash of package
|
||||
},
|
||||
error?: string // Error message if failed
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Patterns
|
||||
|
||||
#### Pattern 1: Basic Pre-Installation Check
|
||||
|
||||
```typescript
|
||||
async function installSkill(packagePath: string) {
|
||||
// Verify signature first
|
||||
const verification = await tools.clawsec_verify_skill_package({ packagePath });
|
||||
const result = JSON.parse(verification.content[0].text);
|
||||
|
||||
if (result.recommendation === 'block') {
|
||||
throw new Error(`Cannot install: ${result.reason || result.error}`);
|
||||
}
|
||||
|
||||
// Signature valid - proceed with extraction
|
||||
extractPackage(packagePath, '/workspace/project/skills/');
|
||||
}
|
||||
```
|
||||
|
||||
#### Pattern 2: Combined Security Checks
|
||||
|
||||
```typescript
|
||||
async function installSkillSafely(packagePath: string, skillName: string) {
|
||||
// Step 1: Verify signature
|
||||
const sigVerify = await tools.clawsec_verify_skill_package({ packagePath });
|
||||
const sigResult = JSON.parse(sigVerify.content[0].text);
|
||||
|
||||
if (!sigResult.valid) {
|
||||
throw new Error(`Signature invalid: ${sigResult.reason}`);
|
||||
}
|
||||
|
||||
// Step 2: Check advisories
|
||||
const advisory = await tools.clawsec_check_skill_safety({ skillName });
|
||||
const advResult = JSON.parse(advisory.content[0].text);
|
||||
|
||||
if (!advResult.safe) {
|
||||
throw new Error(`Known vulnerabilities: ${advResult.advisories.map(a => a.id).join(', ')}`);
|
||||
}
|
||||
|
||||
// Both checks passed - safe to install
|
||||
extractPackage(packagePath, '/workspace/project/skills/');
|
||||
console.log(`✓ Installed ${skillName} (verified + no advisories)`);
|
||||
}
|
||||
```
|
||||
|
||||
#### Pattern 3: Download and Verify Workflow
|
||||
|
||||
```typescript
|
||||
async function downloadAndInstallSkill(url: string) {
|
||||
const packagePath = `/tmp/${Date.now()}-skill.tar.gz`;
|
||||
const signaturePath = `${packagePath}.sig`;
|
||||
|
||||
// Download package
|
||||
await fetch(url).then(r => r.arrayBuffer()).then(buf => {
|
||||
fs.writeFileSync(packagePath, Buffer.from(buf));
|
||||
});
|
||||
|
||||
// Download signature
|
||||
await fetch(`${url}.sig`).then(r => r.text()).then(sig => {
|
||||
fs.writeFileSync(signaturePath, sig);
|
||||
});
|
||||
|
||||
// Verify before installation
|
||||
const verification = await tools.clawsec_verify_skill_package({
|
||||
packagePath,
|
||||
signaturePath
|
||||
});
|
||||
|
||||
const result = JSON.parse(verification.content[0].text);
|
||||
|
||||
if (!result.valid) {
|
||||
fs.unlinkSync(packagePath); // Delete tampered file
|
||||
fs.unlinkSync(signaturePath);
|
||||
throw new Error('Signature verification failed');
|
||||
}
|
||||
|
||||
// Install verified package
|
||||
extractPackage(packagePath, '/workspace/project/skills/');
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(packagePath);
|
||||
fs.unlinkSync(signaturePath);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
const verification = await tools.clawsec_verify_skill_package({ packagePath });
|
||||
const result = JSON.parse(verification.content[0].text);
|
||||
|
||||
// Check result.success first (operation completed)
|
||||
if (!result.success) {
|
||||
console.error('Verification operation failed:', result.error);
|
||||
// Reasons: file not found, service unavailable, timeout
|
||||
return;
|
||||
}
|
||||
|
||||
// Then check result.valid (signature cryptographically valid)
|
||||
if (!result.valid) {
|
||||
console.error('Invalid signature:', result.reason);
|
||||
// Reasons: signature mismatch, tampered package, invalid format
|
||||
return;
|
||||
}
|
||||
|
||||
// Finally check recommendation
|
||||
switch (result.recommendation) {
|
||||
case 'install':
|
||||
console.log('✓ Safe to install');
|
||||
break;
|
||||
case 'block':
|
||||
console.error('⛔ Installation blocked');
|
||||
break;
|
||||
case 'review':
|
||||
console.warn('⚠️ Manual review recommended');
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Properties
|
||||
|
||||
### What Signature Verification Prevents
|
||||
|
||||
✅ **Prevents:**
|
||||
- **Tampering**: Detecting if package contents were modified after signing
|
||||
- **MITM attacks**: Detecting if package was swapped during download
|
||||
- **Malicious mirrors**: Ensuring package comes from trusted source
|
||||
- **Accidental corruption**: Detecting file corruption during transfer
|
||||
|
||||
### What Signature Verification Does NOT Prevent
|
||||
|
||||
❌ **Does Not Prevent:**
|
||||
- **Malicious signed packages**: If the publisher's key is compromised
|
||||
- **Zero-day vulnerabilities**: Bugs unknown to the publisher
|
||||
- **Social engineering**: Convincing users to trust malicious publishers
|
||||
- **Time-of-check-to-time-of-use**: Package modified after verification
|
||||
|
||||
**Defense in Depth**: Combine signature verification with:
|
||||
1. **Advisory checking** (`clawsec_check_skill_safety`)
|
||||
2. **Code review** (manual inspection of skill code)
|
||||
3. **Sandboxing** (run skills in isolated containers)
|
||||
4. **Monitoring** (detect suspicious behavior at runtime)
|
||||
|
||||
### Trust Model
|
||||
|
||||
Signature verification relies on **trust in the public key**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ You trust ClawSec's public key │
|
||||
│ ↓ │
|
||||
│ ClawSec signs package with private key │
|
||||
│ ↓ │
|
||||
│ You verify signature with ClawSec's public key │
|
||||
│ ↓ │
|
||||
│ Signature valid → Package is authentic │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Question**: How do you establish trust in the public key?
|
||||
- **Pinned in repository**: Public key committed to ClawSec repo (trust GitHub)
|
||||
- **HTTPS website**: Download from `https://clawsec.prompt.security/` (trust TLS/CA)
|
||||
- **Out-of-band verification**: Compare key fingerprint via phone, Signal, etc.
|
||||
- **Web of Trust**: Multiple trusted sources publish the same key
|
||||
|
||||
---
|
||||
|
||||
## Key Management
|
||||
|
||||
### ClawSec's Pinned Public Key
|
||||
|
||||
**Location**: `/workspace/project/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem`
|
||||
|
||||
This is the **same key** used for advisory feed verification, providing a single trust anchor for all ClawSec security operations.
|
||||
|
||||
**Key Fingerprint** (for manual verification):
|
||||
```bash
|
||||
# Compute fingerprint of pinned key
|
||||
openssl pkey -pubin -in feed-signing-public.pem -outform DER | \
|
||||
openssl dgst -sha256 -binary | base64
|
||||
# Expected: <will be filled in after key generation>
|
||||
```
|
||||
|
||||
### Public Key Policy
|
||||
|
||||
The verifier always uses the pinned ClawSec public key from this skill package.
|
||||
Runtime public-key overrides are intentionally not supported.
|
||||
|
||||
### Key Rotation
|
||||
|
||||
If ClawSec's signing key is compromised or needs rotation:
|
||||
|
||||
1. **Generate new keypair** (keep private key secure)
|
||||
2. **Sign all packages** with new key
|
||||
3. **Publish new public key** to all distribution channels
|
||||
4. **Update pinned key** in `/workspace/project/skills/clawsec-nanoclaw/advisories/`
|
||||
5. **Deprecate old key** after transition period (e.g., 90 days)
|
||||
|
||||
During transition, support **dual signatures**:
|
||||
- `package.tar.gz.sig` (old key)
|
||||
- `package.tar.gz.sig2` (new key)
|
||||
|
||||
Agents can verify with either key during the overlap period.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "Signature file not found"
|
||||
|
||||
**Cause**: Missing `.sig` file or incorrect path.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check if signature exists
|
||||
ls -l /tmp/skill.tar.gz.sig
|
||||
|
||||
# If missing, download signature
|
||||
curl -o /tmp/skill.tar.gz.sig https://example.com/skill.tar.gz.sig
|
||||
|
||||
# Or specify explicit path
|
||||
clawsec_verify_skill_package({
|
||||
packagePath: '/tmp/skill.tar.gz',
|
||||
signaturePath: '/tmp/custom-signature.sig'
|
||||
})
|
||||
```
|
||||
|
||||
### Error: "Signature verification failed"
|
||||
|
||||
**Cause**: Package was tampered with, or signature doesn't match package.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Re-download package and signature
|
||||
curl -o /tmp/skill.tar.gz https://example.com/skill.tar.gz
|
||||
curl -o /tmp/skill.tar.gz.sig https://example.com/skill.tar.gz.sig
|
||||
|
||||
# Verify manually with OpenSSL
|
||||
openssl dgst -sha512 -verify clawsec-signing-public.pem \
|
||||
-signature /tmp/skill.tar.gz.sig /tmp/skill.tar.gz
|
||||
# Should output: "Verified OK"
|
||||
```
|
||||
|
||||
### Error: "Invalid PEM format"
|
||||
|
||||
**Cause**: Public key file is corrupted or not in PEM format.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check public key format
|
||||
head -1 /path/to/public-key.pem
|
||||
# Should output: "-----BEGIN PUBLIC KEY-----"
|
||||
|
||||
# Re-download public key
|
||||
curl -o clawsec-signing-public.pem \
|
||||
https://clawsec.prompt.security/clawsec-signing-public.pem
|
||||
```
|
||||
|
||||
### Error: "Package file not found"
|
||||
|
||||
**Cause**: Incorrect path or file doesn't exist.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Use absolute paths (required)
|
||||
clawsec_verify_skill_package({
|
||||
packagePath: '/tmp/skill.tar.gz' // ✓ Absolute
|
||||
// packagePath: './skill.tar.gz' // ✗ Relative (won't work)
|
||||
})
|
||||
|
||||
# Verify file exists
|
||||
stat /tmp/skill.tar.gz
|
||||
```
|
||||
|
||||
### Verification Times Out (>5s)
|
||||
|
||||
**Cause**: Large package (>50MB) or slow disk I/O.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check package size
|
||||
ls -lh /tmp/skill.tar.gz
|
||||
|
||||
# For very large packages, verification can take time
|
||||
# Consider splitting into smaller skill modules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Signature File Format
|
||||
|
||||
ClawSec uses **Ed25519 detached signatures** in raw binary format, base64-encoded.
|
||||
|
||||
**File Structure**:
|
||||
```
|
||||
my-skill-1.0.0.tar.gz.sig:
|
||||
Line 1: base64-encoded signature (88 characters)
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
MEQCIDxyz...ABC123==
|
||||
```
|
||||
|
||||
**Properties**:
|
||||
- Algorithm: Ed25519 (EdDSA with Curve25519)
|
||||
- Signature size: 64 bytes (88 characters base64)
|
||||
- Hash function: SHA-512 (internal to Ed25519)
|
||||
- Format: Raw binary, base64-encoded
|
||||
|
||||
**Verification Algorithm**:
|
||||
1. Decode base64 signature → 64-byte binary
|
||||
2. Hash package with SHA-512
|
||||
3. Verify Ed25519 signature(hash, publicKey) → boolean
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Ed25519 Specification (RFC 8032)](https://tools.ietf.org/html/rfc8032)
|
||||
- [OpenSSL Ed25519 Documentation](https://www.openssl.org/docs/man3.0/man7/Ed25519.html)
|
||||
- [ClawSec Security Architecture](https://clawsec.prompt.security/docs/architecture)
|
||||
- [Supply Chain Attack Prevention](https://owasp.org/www-community/attacks/Supply_Chain_Attack)
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0.0
|
||||
**Last Updated**: 2026-02-25
|
||||
**Maintainer**: ClawSec Security Team
|
||||
@@ -0,0 +1,736 @@
|
||||
/**
|
||||
* File Integrity Monitor for NanoClaw
|
||||
*
|
||||
* TypeScript port of ClawSec's soul-guardian with NanoClaw-specific adaptations.
|
||||
*
|
||||
* Key Features:
|
||||
* - SHA-256 baseline tracking for protected files
|
||||
* - Drift detection with unified diff generation
|
||||
* - Auto-restore for critical files (with quarantine)
|
||||
* - Hash-chained tamper-evident audit log
|
||||
* - Per-file policy (restore/alert/ignore modes)
|
||||
*
|
||||
* Security Model:
|
||||
* - Baselines stored on host only (containers access via IPC)
|
||||
* - Atomic file operations for restores
|
||||
* - Refuses to operate on symlinks
|
||||
* - Hash-chained audit log prevents tampering
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
// glob is available when running in the NanoClaw host environment.
|
||||
// For type checking in the clawsec repo, we declare a minimal interface.
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
declare namespace glob {
|
||||
function sync(pattern: string, options?: { nodir?: boolean }): string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PolicyTarget {
|
||||
path?: string;
|
||||
pattern?: string;
|
||||
mode: 'restore' | 'alert' | 'ignore';
|
||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
version: number;
|
||||
description: string;
|
||||
nanoclaw_version: string;
|
||||
targets: PolicyTarget[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface FileBaseline {
|
||||
sha256: string;
|
||||
approved_at: string;
|
||||
approved_by: string;
|
||||
mode: 'restore' | 'alert' | 'ignore';
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface BaselinesManifest {
|
||||
schema_version: string;
|
||||
algorithm: 'sha256';
|
||||
created_at: string;
|
||||
files: Record<string, FileBaseline>;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
ts: string;
|
||||
event: 'init' | 'drift' | 'restore' | 'approve' | 'error';
|
||||
actor: string;
|
||||
note?: string;
|
||||
path: string;
|
||||
mode?: string;
|
||||
expected_sha?: string;
|
||||
found_sha?: string;
|
||||
patch_path?: string;
|
||||
quarantine_path?: string;
|
||||
error?: string;
|
||||
chain?: {
|
||||
prev: string;
|
||||
hash: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DriftedFile {
|
||||
path: string;
|
||||
mode: 'restore' | 'alert';
|
||||
expected_sha: string;
|
||||
found_sha: string;
|
||||
patch_path: string;
|
||||
restored: boolean;
|
||||
quarantine_path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
success: boolean;
|
||||
timestamp: string;
|
||||
drift_detected: boolean;
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: 'ok' | 'drifted' | 'restored' | 'error';
|
||||
mode: string;
|
||||
expected_sha?: string;
|
||||
found_sha?: string;
|
||||
patch_path?: string;
|
||||
quarantine_path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
ok: number;
|
||||
drifted: number;
|
||||
restored: number;
|
||||
alerted: number;
|
||||
errors: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IntegrityMonitorOptions {
|
||||
policyPath: string;
|
||||
stateDir: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const CHAIN_GENESIS = '0'.repeat(64);
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
function utcNowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sha256Hex(data: Buffer | string): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(data);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function sha256File(filePath: string): string {
|
||||
const data = fs.readFileSync(filePath);
|
||||
return sha256Hex(data);
|
||||
}
|
||||
|
||||
function isSymlink(filePath: string): boolean {
|
||||
try {
|
||||
const stats = fs.lstatSync(filePath);
|
||||
return stats.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function refuseSymlink(filePath: string): void {
|
||||
if (isSymlink(filePath)) {
|
||||
throw new Error(`Refusing to operate on symlink: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(dirPath: string): void {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function atomicWrite(filePath: string, data: string | Buffer): void {
|
||||
ensureDir(path.dirname(filePath));
|
||||
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
||||
fs.writeFileSync(tmpPath, data);
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
||||
function unifiedDiff(oldText: string, newText: string, oldLabel: string, newLabel: string): string {
|
||||
// Simple unified diff implementation
|
||||
const oldLines = oldText.split('\n');
|
||||
const newLines = newText.split('\n');
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`--- ${oldLabel}`);
|
||||
lines.push(`+++ ${newLabel}`);
|
||||
lines.push(`@@ -1,${oldLines.length} +1,${newLines.length} @@`);
|
||||
|
||||
for (let i = 0; i < Math.max(oldLines.length, newLines.length); i++) {
|
||||
if (i < oldLines.length && i < newLines.length) {
|
||||
if (oldLines[i] !== newLines[i]) {
|
||||
lines.push(`-${oldLines[i]}`);
|
||||
lines.push(`+${newLines[i]}`);
|
||||
} else {
|
||||
lines.push(` ${oldLines[i]}`);
|
||||
}
|
||||
} else if (i < oldLines.length) {
|
||||
lines.push(`-${oldLines[i]}`);
|
||||
} else {
|
||||
lines.push(`+${newLines[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function safePatchTag(tag: string): string {
|
||||
return tag.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40) || 'patch';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integrity Monitor Class
|
||||
// ============================================================================
|
||||
|
||||
export class IntegrityMonitor {
|
||||
private policyPath: string;
|
||||
private stateDir: string;
|
||||
private baselinesPath: string;
|
||||
private auditPath: string;
|
||||
private approvedDir: string;
|
||||
private patchesDir: string;
|
||||
private quarantineDir: string;
|
||||
|
||||
private policy: Policy | null = null;
|
||||
private baselines: BaselinesManifest | null = null;
|
||||
|
||||
constructor(options: IntegrityMonitorOptions) {
|
||||
this.policyPath = options.policyPath;
|
||||
this.stateDir = options.stateDir;
|
||||
this.baselinesPath = path.join(this.stateDir, 'baselines.json');
|
||||
this.auditPath = path.join(this.stateDir, 'audit.jsonl');
|
||||
this.approvedDir = path.join(this.stateDir, 'approved');
|
||||
this.patchesDir = path.join(this.stateDir, 'patches');
|
||||
this.quarantineDir = path.join(this.stateDir, 'quarantine');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
async init(actor: string = 'system', note: string = 'initial baseline'): Promise<void> {
|
||||
ensureDir(this.stateDir);
|
||||
ensureDir(this.approvedDir);
|
||||
ensureDir(this.patchesDir);
|
||||
ensureDir(this.quarantineDir);
|
||||
|
||||
// Load policy
|
||||
this.policy = this.loadPolicy();
|
||||
|
||||
// Load or create baselines
|
||||
this.baselines = this.loadBaselines();
|
||||
|
||||
// Resolve targets and initialize missing baselines
|
||||
const targets = this.resolveTargets();
|
||||
let initialized = false;
|
||||
|
||||
for (const target of targets) {
|
||||
if (target.mode === 'ignore') continue;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(target.path)) continue;
|
||||
|
||||
refuseSymlink(target.path);
|
||||
|
||||
// Check if already has baseline
|
||||
if (this.baselines.files[target.path]) continue;
|
||||
|
||||
// Create baseline
|
||||
const sha = sha256File(target.path);
|
||||
const snapshot = path.join(this.approvedDir, path.basename(target.path));
|
||||
fs.copyFileSync(target.path, snapshot);
|
||||
|
||||
this.baselines.files[target.path] = {
|
||||
sha256: sha,
|
||||
approved_at: utcNowIso(),
|
||||
approved_by: actor,
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
};
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'init',
|
||||
actor,
|
||||
note,
|
||||
path: target.path,
|
||||
mode: target.mode,
|
||||
expected_sha: sha
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize baseline for ${target.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (initialized) {
|
||||
this.saveBaselines();
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Policy Management
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private loadPolicy(): Policy {
|
||||
const raw = fs.readFileSync(this.policyPath, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
private resolveTargets(): Array<{ path: string; mode: 'restore' | 'alert' | 'ignore'; priority: string }> {
|
||||
if (!this.policy) throw new Error('Policy not loaded');
|
||||
|
||||
const targets: Array<{ path: string; mode: 'restore' | 'alert' | 'ignore'; priority: string }> = [];
|
||||
|
||||
for (const target of this.policy.targets) {
|
||||
if (target.path) {
|
||||
// Direct path
|
||||
targets.push({
|
||||
path: path.resolve(target.path),
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
});
|
||||
} else if (target.pattern) {
|
||||
// Glob pattern
|
||||
try {
|
||||
const matches = glob.sync(target.pattern, { nodir: true });
|
||||
for (const match of matches) {
|
||||
targets.push({
|
||||
path: path.resolve(match),
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to expand pattern ${target.pattern}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private loadBaselines(): BaselinesManifest {
|
||||
if (fs.existsSync(this.baselinesPath)) {
|
||||
const raw = fs.readFileSync(this.baselinesPath, 'utf-8');
|
||||
return this.normalizeBaselines(JSON.parse(raw));
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: '1',
|
||||
algorithm: 'sha256',
|
||||
created_at: utcNowIso(),
|
||||
files: {}
|
||||
};
|
||||
}
|
||||
|
||||
private saveBaselines(): void {
|
||||
const data = JSON.stringify(this.baselines, null, 2);
|
||||
atomicWrite(this.baselinesPath, data);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Audit Log with Hash Chaining
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private getLastAuditHash(): string {
|
||||
if (!fs.existsSync(this.auditPath)) {
|
||||
return CHAIN_GENESIS;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.auditPath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(l => l.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return CHAIN_GENESIS;
|
||||
}
|
||||
|
||||
try {
|
||||
const lastEntry = JSON.parse(lines[lines.length - 1]);
|
||||
return lastEntry.chain?.hash || CHAIN_GENESIS;
|
||||
} catch {
|
||||
return CHAIN_GENESIS;
|
||||
}
|
||||
}
|
||||
|
||||
private appendAudit(entry: Omit<AuditEntry, 'chain'>): void {
|
||||
ensureDir(path.dirname(this.auditPath));
|
||||
|
||||
const prevHash = this.getLastAuditHash();
|
||||
|
||||
// Compute current hash
|
||||
const entryWithoutChain = { ...entry };
|
||||
const payload = prevHash + '\n' + JSON.stringify(entryWithoutChain, Object.keys(entryWithoutChain).sort());
|
||||
const currentHash = sha256Hex(payload);
|
||||
|
||||
const record: AuditEntry = {
|
||||
...entry,
|
||||
chain: {
|
||||
prev: prevHash,
|
||||
hash: currentHash
|
||||
}
|
||||
};
|
||||
|
||||
fs.appendFileSync(this.auditPath, JSON.stringify(record) + '\n');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Drift Detection
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
async checkIntegrity(autoRestore: boolean = true, actor: string = 'agent'): Promise<CheckResult> {
|
||||
if (!this.baselines) {
|
||||
throw new Error('Baselines not loaded. Call init() first.');
|
||||
}
|
||||
|
||||
const result: CheckResult = {
|
||||
success: true,
|
||||
timestamp: utcNowIso(),
|
||||
drift_detected: false,
|
||||
files: [],
|
||||
summary: {
|
||||
total: 0,
|
||||
ok: 0,
|
||||
drifted: 0,
|
||||
restored: 0,
|
||||
alerted: 0,
|
||||
errors: 0
|
||||
}
|
||||
};
|
||||
|
||||
for (const [filePath, baseline] of Object.entries(this.baselines.files)) {
|
||||
result.summary.total++;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'error',
|
||||
mode: baseline.mode,
|
||||
error: 'File not found'
|
||||
});
|
||||
result.summary.errors++;
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'error',
|
||||
actor,
|
||||
path: filePath,
|
||||
error: 'File not found'
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
refuseSymlink(filePath);
|
||||
|
||||
const currentSha = sha256File(filePath);
|
||||
|
||||
if (currentSha === baseline.sha256) {
|
||||
// No drift
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'ok',
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha
|
||||
});
|
||||
result.summary.ok++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drift detected
|
||||
result.drift_detected = true;
|
||||
result.summary.drifted++;
|
||||
|
||||
// Generate diff
|
||||
const snapshot = path.join(this.approvedDir, path.basename(filePath));
|
||||
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 patchPath = path.join(
|
||||
this.patchesDir,
|
||||
`${new Date().toISOString().replace(/[:.]/g, '-')}-drift-${safePatchTag(path.basename(filePath))}.patch`
|
||||
);
|
||||
fs.writeFileSync(patchPath, diff);
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'drift',
|
||||
actor,
|
||||
path: filePath,
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath
|
||||
});
|
||||
|
||||
// Handle based on mode
|
||||
if (baseline.mode === 'restore' && autoRestore) {
|
||||
// Auto-restore
|
||||
try {
|
||||
const quarantinePath = path.join(
|
||||
this.quarantineDir,
|
||||
`${safePatchTag(path.basename(filePath))}.${Date.now()}.quarantine`
|
||||
);
|
||||
fs.copyFileSync(filePath, quarantinePath);
|
||||
|
||||
if (fs.existsSync(snapshot)) {
|
||||
atomicWrite(filePath, fs.readFileSync(snapshot));
|
||||
}
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'restore',
|
||||
actor,
|
||||
path: filePath,
|
||||
mode: baseline.mode,
|
||||
quarantine_path: quarantinePath
|
||||
});
|
||||
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'restored',
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath,
|
||||
quarantine_path: quarantinePath
|
||||
});
|
||||
result.summary.restored++;
|
||||
} catch (error) {
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'error',
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath,
|
||||
error: `Restore failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
result.summary.errors++;
|
||||
}
|
||||
} else {
|
||||
// Alert only
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'drifted',
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath
|
||||
});
|
||||
result.summary.alerted++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'error',
|
||||
mode: baseline.mode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
result.summary.errors++;
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'error',
|
||||
actor,
|
||||
path: filePath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Approve Changes
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
async approveChange(filePath: string, actor: string, note: string = ''): Promise<void> {
|
||||
if (!this.baselines) {
|
||||
throw new Error('Baselines not loaded');
|
||||
}
|
||||
|
||||
const normalizedFilePath = path.resolve(filePath);
|
||||
|
||||
if (!fs.existsSync(normalizedFilePath)) {
|
||||
throw new Error(`File not found: ${normalizedFilePath}`);
|
||||
}
|
||||
|
||||
refuseSymlink(normalizedFilePath);
|
||||
|
||||
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(normalizedFilePath));
|
||||
const oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : '';
|
||||
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(normalizedFilePath))}.patch`
|
||||
);
|
||||
fs.writeFileSync(patchPath, diff);
|
||||
|
||||
// Update baseline
|
||||
if (!this.baselines.files[normalizedFilePath]) {
|
||||
this.baselines.files[normalizedFilePath] = {
|
||||
sha256: currentSha,
|
||||
approved_at: utcNowIso(),
|
||||
approved_by: actor,
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
};
|
||||
} else {
|
||||
this.baselines.files[normalizedFilePath].sha256 = currentSha;
|
||||
this.baselines.files[normalizedFilePath].approved_at = utcNowIso();
|
||||
this.baselines.files[normalizedFilePath].approved_by = actor;
|
||||
}
|
||||
|
||||
// Update snapshot
|
||||
fs.copyFileSync(normalizedFilePath, snapshot);
|
||||
|
||||
// Save and audit
|
||||
this.saveBaselines();
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'approve',
|
||||
actor,
|
||||
note,
|
||||
path: normalizedFilePath,
|
||||
expected_sha: previousSha,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Status and Verification
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getStatus(filePath?: string): any {
|
||||
if (!this.baselines) {
|
||||
throw new Error('Baselines not loaded');
|
||||
}
|
||||
|
||||
const normalizedFilePath = filePath ? path.resolve(filePath) : null;
|
||||
const files = normalizedFilePath
|
||||
? { [normalizedFilePath]: this.baselines.files[normalizedFilePath] }
|
||||
: this.baselines.files;
|
||||
|
||||
return {
|
||||
baseline_age: this.baselines.created_at,
|
||||
files: Object.entries(files).map(([path, baseline]) => ({
|
||||
path,
|
||||
mode: baseline?.mode,
|
||||
priority: baseline?.priority,
|
||||
has_baseline: !!baseline,
|
||||
baseline_sha: baseline?.sha256,
|
||||
approved_at: baseline?.approved_at,
|
||||
snapshot_exists: fs.existsSync(this.approvedDir + '/' + path.split('/').pop())
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
verifyAuditChain(): { valid: boolean; entries: number; errors: string[] } {
|
||||
if (!fs.existsSync(this.auditPath)) {
|
||||
return { valid: true, entries: 0, errors: [] };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.auditPath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(l => l.trim());
|
||||
|
||||
const errors: string[] = [];
|
||||
let prevHash = CHAIN_GENESIS;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
const entry: AuditEntry = JSON.parse(lines[i]);
|
||||
|
||||
if (entry.chain?.prev !== prevHash) {
|
||||
errors.push(`Line ${i + 1}: Chain break (expected prev=${prevHash}, got=${entry.chain?.prev})`);
|
||||
}
|
||||
|
||||
const entryWithoutChain = { ...entry };
|
||||
delete entryWithoutChain.chain;
|
||||
const payload = prevHash + '\n' + JSON.stringify(entryWithoutChain, Object.keys(entryWithoutChain).sort());
|
||||
const expectedHash = sha256Hex(payload);
|
||||
|
||||
if (entry.chain?.hash !== expectedHash) {
|
||||
errors.push(`Line ${i + 1}: Hash mismatch`);
|
||||
}
|
||||
|
||||
prevHash = entry.chain?.hash || CHAIN_GENESIS;
|
||||
} catch (error) {
|
||||
errors.push(`Line ${i + 1}: Parse error - ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
entries: lines.length,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "NanoClaw file integrity monitoring policy",
|
||||
"nanoclaw_version": "0.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"path": "/workspace/project/data/registered_groups.json",
|
||||
"mode": "restore",
|
||||
"priority": "critical",
|
||||
"description": "Group registration config - prevents unauthorized group access"
|
||||
},
|
||||
{
|
||||
"path": "/workspace/group/CLAUDE.md",
|
||||
"mode": "restore",
|
||||
"priority": "high",
|
||||
"description": "Group-specific agent instructions"
|
||||
},
|
||||
{
|
||||
"path": "/workspace/project/groups/global/CLAUDE.md",
|
||||
"mode": "restore",
|
||||
"priority": "high",
|
||||
"description": "Global agent instructions shared across all groups"
|
||||
},
|
||||
{
|
||||
"pattern": "/workspace/project/container/**/*.ts",
|
||||
"mode": "alert",
|
||||
"priority": "medium",
|
||||
"description": "Container runtime code - alert on changes for awareness"
|
||||
},
|
||||
{
|
||||
"pattern": "/workspace/project/host/**/*.ts",
|
||||
"mode": "alert",
|
||||
"priority": "medium",
|
||||
"description": "Host process code - alert on changes for awareness"
|
||||
},
|
||||
{
|
||||
"pattern": "/workspace/ipc/**/*",
|
||||
"mode": "ignore",
|
||||
"priority": "low",
|
||||
"description": "IPC files change constantly - ignore"
|
||||
},
|
||||
{
|
||||
"pattern": "/workspace/group/conversations/**/*",
|
||||
"mode": "ignore",
|
||||
"priority": "low",
|
||||
"description": "Chat history - expected to change frequently"
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"Mode 'restore': Auto-restore file to approved baseline on drift + alert user",
|
||||
"Mode 'alert': Alert user about drift but do not auto-restore",
|
||||
"Mode 'ignore': No monitoring, file changes are expected",
|
||||
"Patterns use glob syntax with ** for recursive matching"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* ClawSec Advisory Cache Manager for NanoClaw
|
||||
*
|
||||
* Manages fetching, verifying, and caching the ClawSec advisory feed.
|
||||
* Runs on the host side (not in container).
|
||||
*
|
||||
* Security:
|
||||
* - Ed25519 signature verification using Node.js crypto
|
||||
* - Fail-closed policy: invalid signature = reject feed
|
||||
* - TLS 1.2+ enforcement with certificate validation
|
||||
* - Public key embedded (not user-modifiable)
|
||||
* - Cache stored in host-managed directory
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { evaluateAdvisoryRisk } from '../lib/risk.js';
|
||||
|
||||
// ClawSec public key (from clawsec-signing-public.pem)
|
||||
const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json';
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
|
||||
export interface Advisory {
|
||||
id: string;
|
||||
severity: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown' | string;
|
||||
exploitability_rationale?: string;
|
||||
affected: string[];
|
||||
}
|
||||
|
||||
export interface FeedPayload {
|
||||
version: string;
|
||||
updated?: string;
|
||||
advisories: Advisory[];
|
||||
}
|
||||
|
||||
export interface AdvisoryCache {
|
||||
feed: FeedPayload;
|
||||
fetchedAt: string;
|
||||
verified: boolean;
|
||||
publicKeyFingerprint: string;
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
info(msg: string | object, ...args: unknown[]): void;
|
||||
error(msg: string | object, ...args: unknown[]): void;
|
||||
warn(msg: string | object, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export class AdvisoryCacheManager {
|
||||
private cache: AdvisoryCache | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private cacheFile: string;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(dataDir: string, logger: Logger) {
|
||||
this.cacheFile = path.join(dataDir, 'clawsec-advisory-cache.json');
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cache manager. Loads cache from disk and refreshes if stale.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.loadCacheFromDisk();
|
||||
|
||||
if (!this.cache || this.isCacheStale()) {
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Failed to initialize advisory cache');
|
||||
// Continue with stale cache if available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh advisory cache from remote feed.
|
||||
* Thread-safe: prevents concurrent refreshes.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
// Prevent concurrent refreshes
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
this.refreshPromise = this._doRefresh();
|
||||
try {
|
||||
await this.refreshPromise;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache. Returns null if cache is stale or missing.
|
||||
*/
|
||||
getCache(): AdvisoryCache | null {
|
||||
if (!this.cache || this.isCacheStale()) {
|
||||
return null;
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache even if stale (for fallback scenarios)
|
||||
*/
|
||||
getCacheAllowStale(): AdvisoryCache | null {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
private async _doRefresh(): Promise<void> {
|
||||
try {
|
||||
this.logger.info('Refreshing advisory cache from ClawSec feed');
|
||||
|
||||
const feed = await this.fetchAndVerifyFeed();
|
||||
const fingerprint = this.calculateKeyFingerprint();
|
||||
|
||||
this.cache = {
|
||||
feed,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
verified: true,
|
||||
publicKeyFingerprint: fingerprint,
|
||||
};
|
||||
|
||||
await this.saveCacheToDisk();
|
||||
this.logger.info({
|
||||
advisories: feed.advisories.length,
|
||||
updated: feed.updated,
|
||||
}, 'Advisory cache refreshed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Failed to refresh advisory cache');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isCacheStale(): boolean {
|
||||
if (!this.cache) return true;
|
||||
const age = Date.now() - Date.parse(this.cache.fetchedAt);
|
||||
return age > CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
private async fetchAndVerifyFeed(): Promise<FeedPayload> {
|
||||
// Fetch feed and signature in parallel
|
||||
const [payloadRaw, signatureRaw] = await Promise.all([
|
||||
this.secureFetch(FEED_URL),
|
||||
this.secureFetch(`${FEED_URL}.sig`),
|
||||
]);
|
||||
|
||||
// Verify Ed25519 signature
|
||||
if (!this.verifySignature(payloadRaw, signatureRaw)) {
|
||||
throw new Error('Feed signature verification failed (Ed25519)');
|
||||
}
|
||||
|
||||
// Parse and validate
|
||||
const feed = JSON.parse(payloadRaw) as FeedPayload;
|
||||
if (!this.isValidFeed(feed)) {
|
||||
throw new Error('Invalid feed format');
|
||||
}
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
private async secureFetch(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create secure HTTPS agent with TLS 1.2+ enforcement
|
||||
const agent = new https.Agent({
|
||||
minVersion: 'TLSv1.2',
|
||||
rejectUnauthorized: true,
|
||||
ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256',
|
||||
});
|
||||
|
||||
const req = https.get(url, {
|
||||
agent,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
headers: {
|
||||
'User-Agent': 'NanoClaw/1.0',
|
||||
'Accept': 'application/json,text/plain',
|
||||
},
|
||||
}, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', reject);
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Timeout fetching ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private verifySignature(payload: string, signatureBase64: string): boolean {
|
||||
try {
|
||||
// Decode base64 signature
|
||||
const trimmed = signatureBase64.trim();
|
||||
let encoded = trimmed;
|
||||
|
||||
// Handle JSON-wrapped signature: {"signature": "base64..."}
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed.signature === 'string') {
|
||||
encoded = parsed.signature;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, use as-is
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = encoded.replace(/\s+/g, '');
|
||||
const sigBuffer = Buffer.from(normalized, 'base64');
|
||||
|
||||
// Verify Ed25519 signature using Node.js crypto
|
||||
const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM);
|
||||
return crypto.verify(
|
||||
null, // algorithm null = Ed25519 raw mode
|
||||
Buffer.from(payload, 'utf8'),
|
||||
publicKey,
|
||||
sigBuffer
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, 'Signature verification failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidFeed(feed: unknown): feed is FeedPayload {
|
||||
if (typeof feed !== 'object' || !feed) return false;
|
||||
const f = feed as FeedPayload;
|
||||
|
||||
if (typeof f.version !== 'string' || !f.version.trim()) return false;
|
||||
if (!Array.isArray(f.advisories)) return false;
|
||||
|
||||
// Validate each advisory
|
||||
return f.advisories.every((a: unknown) => {
|
||||
if (typeof a !== 'object' || !a) return false;
|
||||
const advisory = a as Advisory;
|
||||
|
||||
return (
|
||||
typeof advisory.id === 'string' &&
|
||||
advisory.id.trim() !== '' &&
|
||||
typeof advisory.severity === 'string' &&
|
||||
advisory.severity.trim() !== '' &&
|
||||
Array.isArray(advisory.affected) &&
|
||||
advisory.affected.every(
|
||||
(affected) => typeof affected === 'string' && affected.trim() !== ''
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private calculateKeyFingerprint(): string {
|
||||
const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM);
|
||||
const der = publicKey.export({ type: 'spki', format: 'der' });
|
||||
return crypto.createHash('sha256').update(der).digest('hex');
|
||||
}
|
||||
|
||||
private async loadCacheFromDisk(): Promise<void> {
|
||||
try {
|
||||
const data = await fs.readFile(this.cacheFile, 'utf8');
|
||||
const parsed = JSON.parse(data) as AdvisoryCache;
|
||||
|
||||
// Validate cache structure
|
||||
if (this.isValidCache(parsed)) {
|
||||
this.cache = parsed;
|
||||
this.logger.info({
|
||||
age: Date.now() - Date.parse(parsed.fetchedAt),
|
||||
advisories: parsed.feed.advisories.length,
|
||||
}, 'Loaded advisory cache from disk');
|
||||
} else {
|
||||
this.logger.warn('Invalid cache format on disk, discarding');
|
||||
this.cache = null;
|
||||
}
|
||||
} catch {
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidCache(cache: unknown): cache is AdvisoryCache {
|
||||
if (typeof cache !== 'object' || !cache) return false;
|
||||
const c = cache as AdvisoryCache;
|
||||
|
||||
return (
|
||||
this.isValidFeed(c.feed) &&
|
||||
typeof c.fetchedAt === 'string' &&
|
||||
typeof c.verified === 'boolean' &&
|
||||
typeof c.publicKeyFingerprint === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
private async saveCacheToDisk(): Promise<void> {
|
||||
if (!this.cache) return;
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(this.cacheFile), { recursive: true });
|
||||
|
||||
// Atomic write: temp file then rename
|
||||
const tempFile = `${this.cacheFile}.tmp`;
|
||||
await fs.writeFile(tempFile, JSON.stringify(this.cache, null, 2), 'utf8');
|
||||
await fs.rename(tempFile, this.cacheFile);
|
||||
|
||||
this.logger.info({ path: this.cacheFile }, 'Advisory cache saved to disk');
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Failed to save advisory cache to disk');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Match advisories against installed skills
|
||||
*/
|
||||
export function findAdvisoryMatches(
|
||||
advisories: Advisory[],
|
||||
skills: Array<{ name: string; version: string | null; dirName: string }>
|
||||
): Array<{
|
||||
advisory: Advisory;
|
||||
skill: { name: string; version: string | null; dirName: string };
|
||||
matchedAffected: string[];
|
||||
}> {
|
||||
const matches: Array<{
|
||||
advisory: Advisory;
|
||||
skill: { name: string; version: string | null; dirName: string };
|
||||
matchedAffected: string[];
|
||||
}> = [];
|
||||
|
||||
for (const advisory of advisories) {
|
||||
for (const skill of skills) {
|
||||
const matchedAffected: string[] = [];
|
||||
|
||||
for (const affected of advisory.affected) {
|
||||
// Parse affected specifier: skill-name or skill-name@version
|
||||
const atIndex = affected.lastIndexOf('@');
|
||||
const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected;
|
||||
const _affectedVersion = atIndex > 0 ? affected.slice(atIndex + 1) : '*';
|
||||
|
||||
// Match by name or directory name
|
||||
if (affectedName === skill.name || affectedName === skill.dirName) {
|
||||
// TODO: implement version range matching
|
||||
matchedAffected.push(affected);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedAffected.length > 0) {
|
||||
matches.push({ advisory, skill, matchedAffected });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Evaluate safety recommendation for a skill
|
||||
*/
|
||||
export function evaluateSkillSafety(advisories: Advisory[]): {
|
||||
safe: boolean;
|
||||
recommendation: 'install' | 'block' | 'review';
|
||||
reason: string;
|
||||
} {
|
||||
return evaluateAdvisoryRisk(advisories);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* ClawSec File Integrity Monitoring IPC Handler for NanoClaw Host
|
||||
*
|
||||
* Add these handlers to /workspace/project/src/ipc.ts
|
||||
*
|
||||
* This processes integrity monitoring requests from agents running in containers.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { IntegrityMonitor } from '../guardian/integrity-monitor';
|
||||
|
||||
// ============================================================================
|
||||
// Integrity Service (Singleton)
|
||||
// ============================================================================
|
||||
|
||||
export class IntegrityService {
|
||||
private monitor: IntegrityMonitor | null = null;
|
||||
private initialized = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
this.monitor = new IntegrityMonitor({
|
||||
policyPath: '/workspace/project/skills/clawsec-nanoclaw/guardian/policy.json',
|
||||
stateDir: '/workspace/project/data/soul-guardian'
|
||||
});
|
||||
|
||||
// Initialize baselines on first run
|
||||
await this.monitor.init('system', 'initial baseline');
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[IntegrityService] Initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[IntegrityService] Initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getMonitor(): IntegrityMonitor {
|
||||
if (!this.monitor) {
|
||||
throw new Error('IntegrityService not initialized');
|
||||
}
|
||||
return this.monitor;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
let integrityServiceInstance: IntegrityService | null = null;
|
||||
|
||||
export function getIntegrityService(): IntegrityService {
|
||||
if (!integrityServiceInstance) {
|
||||
integrityServiceInstance = new IntegrityService();
|
||||
}
|
||||
return integrityServiceInstance;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IPC Handler Integration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add this to the IpcDeps interface in /workspace/project/src/ipc.ts:
|
||||
*
|
||||
* export interface IpcDeps {
|
||||
* // ... existing deps
|
||||
* integrityService?: IntegrityService;
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add these cases to the switch statement in processTaskIpc:
|
||||
*/
|
||||
|
||||
export async function handleIntegrityIpc(
|
||||
task: any,
|
||||
deps: { integrityService?: IntegrityService },
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { type, requestId, groupFolder: _groupFolder } = task;
|
||||
|
||||
if (!deps.integrityService) {
|
||||
logger.warn({ task }, 'IntegrityService not available');
|
||||
if (requestId) {
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: 'IntegrityService not initialized'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const service = deps.integrityService;
|
||||
|
||||
if (!service.isInitialized()) {
|
||||
try {
|
||||
await service.initialize();
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to initialize IntegrityService');
|
||||
if (requestId) {
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'integrity_check':
|
||||
await handleIntegrityCheck(task, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_approve':
|
||||
await handleIntegrityApprove(task, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_status':
|
||||
await handleIntegrityStatus(task, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_verify_audit':
|
||||
await handleIntegrityVerifyAudit(task, service, logger);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn({ type }, 'Unknown integrity task type');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Individual Handlers
|
||||
// ============================================================================
|
||||
|
||||
async function handleIntegrityCheck(
|
||||
task: any,
|
||||
service: IntegrityService,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { requestId, mode, autoRestore, groupFolder } = task;
|
||||
|
||||
logger.info({ requestId, groupFolder }, 'Processing integrity_check');
|
||||
|
||||
try {
|
||||
const monitor = service.getMonitor();
|
||||
|
||||
if (mode === 'status') {
|
||||
// Status mode: just return baseline info
|
||||
const status = monitor.getStatus();
|
||||
writeResult(requestId, {
|
||||
success: true,
|
||||
mode: 'status',
|
||||
...status
|
||||
});
|
||||
} else {
|
||||
// Check mode: detect drift and optionally restore
|
||||
const result = await monitor.checkIntegrity(autoRestore !== false, 'agent');
|
||||
|
||||
writeResult(requestId, result);
|
||||
|
||||
if (result.drift_detected) {
|
||||
logger.warn(
|
||||
{ requestId, drifted: result.summary.drifted, restored: result.summary.restored },
|
||||
'Integrity drift detected'
|
||||
);
|
||||
} else {
|
||||
logger.info({ requestId }, 'Integrity check passed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, requestId }, 'Integrity check failed');
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntegrityApprove(
|
||||
task: any,
|
||||
service: IntegrityService,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { requestId, path: filePath, note, approvedBy, groupFolder } = task;
|
||||
|
||||
logger.info({ requestId, filePath, groupFolder }, 'Processing integrity_approve');
|
||||
|
||||
try {
|
||||
const monitor = service.getMonitor();
|
||||
|
||||
await monitor.approveChange(filePath, approvedBy || 'agent', note || '');
|
||||
|
||||
writeResult(requestId, {
|
||||
success: true,
|
||||
path: filePath,
|
||||
approved_at: new Date().toISOString(),
|
||||
approved_by: approvedBy,
|
||||
note
|
||||
});
|
||||
|
||||
logger.info({ requestId, filePath }, 'File change approved');
|
||||
} catch (error) {
|
||||
logger.error({ error, requestId, filePath }, 'Approve change failed');
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
path: filePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntegrityStatus(
|
||||
task: any,
|
||||
service: IntegrityService,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { requestId, path: filePath, groupFolder } = task;
|
||||
|
||||
logger.info({ requestId, filePath, groupFolder }, 'Processing integrity_status');
|
||||
|
||||
try {
|
||||
const monitor = service.getMonitor();
|
||||
const status = monitor.getStatus(filePath);
|
||||
|
||||
writeResult(requestId, {
|
||||
success: true,
|
||||
...status
|
||||
});
|
||||
|
||||
logger.info({ requestId }, 'Status retrieved');
|
||||
} catch (error) {
|
||||
logger.error({ error, requestId }, 'Status check failed');
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntegrityVerifyAudit(
|
||||
task: any,
|
||||
service: IntegrityService,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { requestId, groupFolder } = task;
|
||||
|
||||
logger.info({ requestId, groupFolder }, 'Processing integrity_verify_audit');
|
||||
|
||||
try {
|
||||
const monitor = service.getMonitor();
|
||||
const verification = monitor.verifyAuditChain();
|
||||
|
||||
writeResult(requestId, {
|
||||
success: true,
|
||||
...verification
|
||||
});
|
||||
|
||||
if (!verification.valid) {
|
||||
logger.error({ requestId, errors: verification.errors }, 'Audit chain verification failed');
|
||||
} else {
|
||||
logger.info({ requestId, entries: verification.entries }, 'Audit chain verified');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, requestId }, 'Audit verification failed');
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function writeResult(requestId: string, result: any): void {
|
||||
const resultDir = '/workspace/ipc/clawsec_results';
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(resultDir)) {
|
||||
fs.mkdirSync(resultDir, { recursive: true });
|
||||
}
|
||||
|
||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration Instructions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* To integrate into NanoClaw host process:
|
||||
*
|
||||
* 1. Add IntegrityService to IpcDeps in src/ipc.ts:
|
||||
*
|
||||
* import { IntegrityService, getIntegrityService } from '../skills/clawsec-nanoclaw/host-services/integrity-handler';
|
||||
*
|
||||
* export interface IpcDeps {
|
||||
* // ... existing deps
|
||||
* integrityService?: IntegrityService;
|
||||
* }
|
||||
*
|
||||
* 2. Initialize in main.ts:
|
||||
*
|
||||
* const integrityService = getIntegrityService();
|
||||
* await integrityService.initialize();
|
||||
*
|
||||
* const ipcDeps: IpcDeps = {
|
||||
* // ... existing deps
|
||||
* integrityService
|
||||
* };
|
||||
*
|
||||
* 3. Add handler calls in processTaskIpc switch statement:
|
||||
*
|
||||
* case 'integrity_check':
|
||||
* case 'integrity_approve':
|
||||
* case 'integrity_status':
|
||||
* case 'integrity_verify_audit':
|
||||
* await handleIntegrityIpc(task, deps, logger);
|
||||
* break;
|
||||
*
|
||||
* 4. Ensure /workspace/ipc/clawsec_results/ directory exists and is writable
|
||||
*
|
||||
* 5. Ensure /workspace/project/data/soul-guardian/ directory exists and is writable
|
||||
*/
|
||||
|
||||
// Example scheduled task for continuous monitoring:
|
||||
//
|
||||
// schedule_task({
|
||||
// prompt: `
|
||||
// Run clawsec_check_integrity to check for file tampering.
|
||||
// If drift_detected is true and files were restored, send alert:
|
||||
// "SECURITY: Unauthorized changes detected and reverted in:
|
||||
// [list restored files with their paths]
|
||||
// Review patches in /workspace/project/data/soul-guardian/patches/"
|
||||
// `,
|
||||
// schedule_type: 'cron',
|
||||
// schedule_value: '*/30 * * * *', // Every 30 minutes
|
||||
// context_mode: 'isolated'
|
||||
// });
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* ClawSec Advisory Feed IPC Handler Additions for NanoClaw
|
||||
*
|
||||
* Add this case to the switch statement in /workspace/project/src/ipc.ts
|
||||
* inside the processTaskIpc function.
|
||||
*
|
||||
* This handler processes advisory cache refresh requests from agents.
|
||||
*/
|
||||
|
||||
import { AdvisoryCacheManager } from './advisory-cache';
|
||||
import { SkillSignatureVerifier } from './skill-signature-handler';
|
||||
|
||||
// Add to IpcDeps interface:
|
||||
export interface IpcDeps {
|
||||
advisoryCacheManager?: AdvisoryCacheManager;
|
||||
signatureVerifier?: SkillSignatureVerifier;
|
||||
}
|
||||
|
||||
interface IpcLogger {
|
||||
info(obj: Record<string, unknown>, msg?: string): void;
|
||||
warn(obj: Record<string, unknown>, msg?: string): void;
|
||||
error(obj: Record<string, unknown>, msg?: string): void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IpcTask = Record<string, any>;
|
||||
|
||||
/**
|
||||
* Placeholder for the host-side writeResponse function.
|
||||
* The actual implementation lives in the NanoClaw host process.
|
||||
*/
|
||||
declare function writeResponse(requestId: string, data: Record<string, unknown>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Handle advisory and signature IPC tasks.
|
||||
*
|
||||
* In the host process, call this from the processTaskIpc switch statement
|
||||
* for the 'refresh_advisory_cache' and 'verify_skill_signature' cases.
|
||||
*/
|
||||
export async function handleAdvisoryIpc(
|
||||
task: IpcTask,
|
||||
deps: IpcDeps,
|
||||
logger: IpcLogger,
|
||||
sourceGroup: string,
|
||||
): Promise<void> {
|
||||
switch (task.type) {
|
||||
case 'refresh_advisory_cache':
|
||||
// Any group can request cache refresh (rate-limited by cache manager)
|
||||
logger.info({ sourceGroup }, 'Advisory cache refresh requested via IPC');
|
||||
if (deps.advisoryCacheManager) {
|
||||
try {
|
||||
await deps.advisoryCacheManager.refresh();
|
||||
logger.info({ sourceGroup }, 'Advisory cache refreshed successfully');
|
||||
} catch (error) {
|
||||
logger.error({ error, sourceGroup }, 'Advisory cache refresh failed');
|
||||
}
|
||||
} else {
|
||||
logger.warn({ sourceGroup }, 'Advisory cache manager not initialized');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'verify_skill_signature': {
|
||||
// Skill signature verification (Phase 1)
|
||||
const { requestId, packagePath, signaturePath } = task;
|
||||
|
||||
logger.info({ sourceGroup, requestId, packagePath }, 'Verifying skill signature');
|
||||
|
||||
try {
|
||||
if (!deps.signatureVerifier) {
|
||||
throw new Error('Signature verification service not available');
|
||||
}
|
||||
|
||||
const result = await deps.signatureVerifier.verify({
|
||||
packagePath,
|
||||
signaturePath,
|
||||
});
|
||||
|
||||
await writeResponse(requestId, {
|
||||
success: true,
|
||||
message: result.valid ? 'Signature valid' : 'Signature invalid',
|
||||
data: result,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ sourceGroup, requestId, valid: result.valid, signer: result.signer },
|
||||
'Signature verification completed'
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error & { code?: string };
|
||||
logger.error({ error, sourceGroup, requestId, packagePath }, 'Signature verification failed');
|
||||
|
||||
const errorCode = err.code || 'CRYPTO_ERROR';
|
||||
await writeResponse(requestId, {
|
||||
success: false,
|
||||
message: err.message || 'Verification failed',
|
||||
error: {
|
||||
code: errorCode,
|
||||
details: error
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Skill Signature Verification Handler for NanoClaw
|
||||
*
|
||||
* Verifies Ed25519 signatures on skill packages to prevent supply chain attacks.
|
||||
* Uses the same pinned public key as advisory feed verification.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
verifyDetachedSignatureWithDetails,
|
||||
loadPublicKey,
|
||||
sha256File,
|
||||
SecurityPolicyError
|
||||
} from '../lib/signatures.js';
|
||||
|
||||
/**
|
||||
* Default location of ClawSec's pinned public key (same as advisory feed)
|
||||
*/
|
||||
const DEFAULT_PUBLIC_KEY_PATH = path.join(
|
||||
__dirname,
|
||||
'../advisories/feed-signing-public.pem'
|
||||
);
|
||||
|
||||
/**
|
||||
* Verification result interface
|
||||
*/
|
||||
export interface VerificationResult {
|
||||
valid: boolean;
|
||||
signer: string | null;
|
||||
packageHash: string;
|
||||
verifiedAt: string;
|
||||
algorithm: 'Ed25519';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification parameters interface
|
||||
*/
|
||||
export interface VerifyParams {
|
||||
packagePath: string;
|
||||
signaturePath: string;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Service class for skill package signature verification
|
||||
*/
|
||||
export class SkillSignatureVerifier {
|
||||
private publicKeyPath: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private logger: any;
|
||||
|
||||
constructor(
|
||||
publicKeyPath: string = DEFAULT_PUBLIC_KEY_PATH,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logger?: any
|
||||
) {
|
||||
this.publicKeyPath = publicKeyPath;
|
||||
this.logger = logger || console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Ed25519 signature of a skill package
|
||||
*/
|
||||
async verify(params: VerifyParams): Promise<VerificationResult> {
|
||||
const {
|
||||
packagePath,
|
||||
signaturePath,
|
||||
} = params;
|
||||
|
||||
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: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
// 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: `Public key file not found: ${this.publicKeyPath}`
|
||||
};
|
||||
}
|
||||
|
||||
keyPem = fs.readFileSync(this.publicKeyPath, 'utf8');
|
||||
loadPublicKey(keyPem); // Validate pinned key
|
||||
} catch (error) {
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: `Failed to load public key: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
|
||||
// Compute package hash (always, for integrity tracking)
|
||||
let packageHash: string;
|
||||
try {
|
||||
packageHash = sha256File(validatedPackagePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: `Failed to compute package hash: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const verificationResult = verifyDetachedSignatureWithDetails(
|
||||
validatedPackagePath,
|
||||
validatedSignaturePath,
|
||||
keyPem
|
||||
);
|
||||
|
||||
// Return structured result
|
||||
return {
|
||||
valid: verificationResult.valid,
|
||||
signer: verificationResult.valid ? 'clawsec' : null,
|
||||
packageHash,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: verificationResult.error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public key fingerprint for auditing
|
||||
*/
|
||||
getPublicKeyFingerprint(): string {
|
||||
try {
|
||||
const keyPem = fs.readFileSync(this.publicKeyPath, 'utf8');
|
||||
const keyObject = loadPublicKey(keyPem);
|
||||
const _keyDer = keyObject.export({ type: 'spki', format: 'der' });
|
||||
return `sha256:${sha256File(this.publicKeyPath).substring(0, 16)}`;
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Failed to compute public key fingerprint');
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error codes for IPC responses
|
||||
*/
|
||||
export const ErrorCodes = {
|
||||
SIGNATURE_INVALID: 'SIGNATURE_INVALID',
|
||||
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
|
||||
CRYPTO_ERROR: 'CRYPTO_ERROR',
|
||||
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Map verification errors to standard error codes
|
||||
*/
|
||||
export function mapErrorCode(error: string): string {
|
||||
if (error.includes('not found')) {
|
||||
return ErrorCodes.FILE_NOT_FOUND;
|
||||
}
|
||||
if (error.includes('Invalid signature') || error.includes('verification failed')) {
|
||||
return ErrorCodes.SIGNATURE_INVALID;
|
||||
}
|
||||
if (error.includes('public key') || error.includes('PEM')) {
|
||||
return ErrorCodes.CRYPTO_ERROR;
|
||||
}
|
||||
return ErrorCodes.CRYPTO_ERROR;
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Advisory Feed Loading and Matching for NanoClaw
|
||||
* Ported from ClawSec's feed.mjs with fail-closed verification
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
Advisory,
|
||||
AdvisoryFeed,
|
||||
AdvisoryMatch,
|
||||
AffectedSpecifier,
|
||||
SignatureVerificationOptions,
|
||||
} from './types.js';
|
||||
import {
|
||||
verifySignedPayload,
|
||||
parseChecksumsManifest,
|
||||
verifyChecksums,
|
||||
fetchText,
|
||||
defaultChecksumsUrl,
|
||||
SecurityPolicyError,
|
||||
} from './signatures.js';
|
||||
|
||||
const DEFAULT_FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json';
|
||||
|
||||
/**
|
||||
* Validates that a payload is a valid advisory feed.
|
||||
*/
|
||||
export function isValidFeedPayload(raw: unknown): raw is AdvisoryFeed {
|
||||
if (typeof raw !== 'object' || raw === null) return false;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.version !== 'string' || !obj.version.trim()) return false;
|
||||
if (!Array.isArray(obj.advisories)) return false;
|
||||
|
||||
for (const advisory of obj.advisories) {
|
||||
if (typeof advisory !== 'object' || advisory === null) return false;
|
||||
const adv = advisory as Record<string, unknown>;
|
||||
|
||||
if (typeof adv.id !== 'string' || !adv.id.trim()) return false;
|
||||
if (typeof adv.severity !== 'string' || !adv.severity.trim()) return false;
|
||||
if (!Array.isArray(adv.affected)) return false;
|
||||
if (!adv.affected.every((entry) => typeof entry === 'string' && entry.trim())) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an affected specifier like "skill-name@version-spec".
|
||||
*/
|
||||
export function parseAffectedSpecifier(rawSpecifier: string): AffectedSpecifier | null {
|
||||
const specifier = rawSpecifier.trim();
|
||||
if (!specifier) return null;
|
||||
|
||||
const atIndex = specifier.lastIndexOf('@');
|
||||
if (atIndex <= 0) {
|
||||
return { name: specifier, versionSpec: '*' };
|
||||
}
|
||||
|
||||
return {
|
||||
name: specifier.slice(0, atIndex),
|
||||
versionSpec: specifier.slice(atIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a skill name for comparison.
|
||||
*/
|
||||
export function normalizeSkillName(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/[^a-z0-9-]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version matches a version specifier.
|
||||
* Supports: exact match, semver range (^, ~, *), wildcards
|
||||
*/
|
||||
export function versionMatches(version: string, versionSpec: string): boolean {
|
||||
const v = version.trim();
|
||||
const spec = versionSpec.trim();
|
||||
|
||||
// Wildcard matches everything
|
||||
if (spec === '*' || spec === '') return true;
|
||||
|
||||
// Exact match
|
||||
if (v === spec) return true;
|
||||
|
||||
// Parse semver components
|
||||
const parseVersion = (ver: string): number[] => {
|
||||
const match = ver.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) return [];
|
||||
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
||||
};
|
||||
|
||||
const vParts = parseVersion(v);
|
||||
const specParts = parseVersion(spec.replace(/^[~^]/, ''));
|
||||
|
||||
if (vParts.length === 0 || specParts.length === 0) return false;
|
||||
|
||||
// Caret range (^1.2.3): compatible with 1.x.x where x >= 2.3
|
||||
if (spec.startsWith('^')) {
|
||||
if (vParts[0] !== specParts[0]) return false;
|
||||
if (vParts[0] === 0) {
|
||||
// ^0.2.3 means 0.2.x where x >= 3
|
||||
if (vParts[1] !== specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
}
|
||||
// ^1.2.3 means 1.x.x where x.x >= 2.3
|
||||
if (vParts[1] > specParts[1]) return true;
|
||||
if (vParts[1] < specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
}
|
||||
|
||||
// Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3)
|
||||
if (spec.startsWith('~')) {
|
||||
if (vParts[0] !== specParts[0]) return false;
|
||||
if (vParts[1] !== specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an affected specifier matches a skill name/version.
|
||||
* Optionally matches against a skill directory name as alias.
|
||||
*/
|
||||
export function matchesAffectedSpecifier(
|
||||
affected: string,
|
||||
skillName: string,
|
||||
skillVersion: string | null,
|
||||
skillDirName?: string
|
||||
): boolean {
|
||||
const parsed = parseAffectedSpecifier(affected);
|
||||
if (!parsed) return false;
|
||||
|
||||
const normalizedTarget = normalizeSkillName(parsed.name);
|
||||
const normalizedSkillName = normalizeSkillName(skillName);
|
||||
const normalizedDirName = skillDirName ? normalizeSkillName(skillDirName) : null;
|
||||
|
||||
if (normalizedTarget !== normalizedSkillName && normalizedTarget !== normalizedDirName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!skillVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return versionMatches(skillVersion, parsed.versionSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads advisory feed from a remote URL with signature verification.
|
||||
*/
|
||||
export async function loadRemoteFeed(
|
||||
feedUrl: string,
|
||||
options: SignatureVerificationOptions
|
||||
): Promise<AdvisoryFeed | null> {
|
||||
const signatureUrl = options.signatureUrl || `${feedUrl}.sig`;
|
||||
const checksumsUrl = options.checksumsUrl || defaultChecksumsUrl(feedUrl);
|
||||
const checksumsSignatureUrl = options.checksumsSignatureUrl || `${checksumsUrl}.sig`;
|
||||
const publicKeyPem = options.publicKeyPem;
|
||||
const checksumsPublicKeyPem = options.checksumsPublicKeyPem || publicKeyPem;
|
||||
const allowUnsigned = options.allowUnsigned || false;
|
||||
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||
|
||||
try {
|
||||
const payloadRaw = await fetchText(feedUrl);
|
||||
if (!payloadRaw) return null;
|
||||
|
||||
if (!allowUnsigned) {
|
||||
const signatureRaw = await fetchText(signatureUrl);
|
||||
if (!signatureRaw) return null;
|
||||
|
||||
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify checksum manifest if available
|
||||
if (verifyChecksumManifest) {
|
||||
const checksumsRaw = await fetchText(checksumsUrl);
|
||||
const checksumsSignatureRaw = await fetchText(checksumsSignatureUrl);
|
||||
|
||||
// Only proceed if BOTH checksum files are present
|
||||
if (checksumsRaw && checksumsSignatureRaw) {
|
||||
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
||||
return null; // Fail-closed: invalid signature
|
||||
}
|
||||
|
||||
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
||||
const checksumFeedEntry = feedUrl.split('/').pop() || 'feed.json';
|
||||
const checksumSignatureEntry = signatureUrl.split('/').pop() || 'feed.json.sig';
|
||||
verifyChecksums(checksumsManifest, {
|
||||
[checksumFeedEntry]: payloadRaw,
|
||||
[checksumSignatureEntry]: signatureRaw,
|
||||
});
|
||||
}
|
||||
// If checksum files missing: continue without checksum verification
|
||||
// (feed signature was already verified above)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
if (!isValidFeedPayload(payload)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Security policy violations return null to allow graceful fallback to local feed
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
return null;
|
||||
}
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads advisory feed from a local file with signature verification.
|
||||
*/
|
||||
export async function loadLocalFeed(
|
||||
feedPath: string,
|
||||
options: SignatureVerificationOptions
|
||||
): Promise<AdvisoryFeed> {
|
||||
const signaturePath = options.signatureUrl || `${feedPath}.sig`;
|
||||
const checksumsPath = options.checksumsUrl || path.join(path.dirname(feedPath), 'checksums.json');
|
||||
const checksumsSignaturePath = options.checksumsSignatureUrl || `${checksumsPath}.sig`;
|
||||
const publicKeyPem = options.publicKeyPem;
|
||||
const checksumsPublicKeyPem = options.checksumsPublicKeyPem || publicKeyPem;
|
||||
const allowUnsigned = options.allowUnsigned || false;
|
||||
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||
|
||||
const payloadRaw = await fs.readFile(feedPath, 'utf8');
|
||||
|
||||
if (!allowUnsigned) {
|
||||
const signatureRaw = await fs.readFile(signaturePath, 'utf8');
|
||||
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||
throw new Error(`Feed signature verification failed for local feed: ${feedPath}`);
|
||||
}
|
||||
|
||||
if (verifyChecksumManifest) {
|
||||
const checksumsRaw = await fs.readFile(checksumsPath, 'utf8');
|
||||
const checksumsSignatureRaw = await fs.readFile(checksumsSignaturePath, 'utf8');
|
||||
|
||||
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
||||
throw new Error(`Checksum manifest signature verification failed: ${checksumsPath}`);
|
||||
}
|
||||
|
||||
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
||||
const checksumFeedEntry = path.basename(feedPath);
|
||||
const checksumSignatureEntry = path.basename(signaturePath);
|
||||
verifyChecksums(checksumsManifest, {
|
||||
[checksumFeedEntry]: payloadRaw,
|
||||
[checksumSignatureEntry]: signatureRaw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
if (!isValidFeedPayload(payload)) {
|
||||
throw new Error(`Invalid advisory feed format: ${feedPath}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads advisory feed from remote or falls back to local.
|
||||
*/
|
||||
export async function loadFeed(
|
||||
feedUrl: string = DEFAULT_FEED_URL,
|
||||
localFeedPath: string,
|
||||
publicKeyPem: string,
|
||||
allowUnsigned: boolean = false
|
||||
): Promise<{ feed: AdvisoryFeed; source: string }> {
|
||||
const options: SignatureVerificationOptions = {
|
||||
publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest: true,
|
||||
};
|
||||
|
||||
// Try remote feed first
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl, options);
|
||||
if (remoteFeed) {
|
||||
return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
||||
}
|
||||
|
||||
// Fall back to local feed
|
||||
const localFeed = await loadLocalFeed(localFeedPath, options);
|
||||
return { feed: localFeed, source: `local:${localFeedPath}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an advisory looks high-risk.
|
||||
*/
|
||||
export function advisoryLooksHighRisk(advisory: Advisory): boolean {
|
||||
const type = advisory.type.toLowerCase();
|
||||
const severity = advisory.severity.toLowerCase();
|
||||
const exploitability = (advisory.exploitability_score || 'unknown').toLowerCase();
|
||||
const combined = `${advisory.title} ${advisory.description} ${advisory.action}`.toLowerCase();
|
||||
|
||||
if (type.includes('malicious')) return true;
|
||||
if (severity === 'critical') return true;
|
||||
if (exploitability === 'high') return true;
|
||||
if (/\b(malicious|exfiltrate|exfiltration|backdoor|trojan|stealer|credential theft)\b/.test(combined)) return true;
|
||||
if (/\b(remove|uninstall|disable|do not use|quarantine)\b/.test(combined)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds advisory matches for a skill.
|
||||
*/
|
||||
export function findAdvisoryMatches(
|
||||
feed: AdvisoryFeed,
|
||||
skillName: string,
|
||||
version: string | null
|
||||
): AdvisoryMatch[] {
|
||||
const matches: AdvisoryMatch[] = [];
|
||||
|
||||
for (const advisory of feed.advisories) {
|
||||
const affected = advisory.affected || [];
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
for (const specifier of affected) {
|
||||
if (!matchesAffectedSpecifier(specifier, skillName, version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match found
|
||||
matches.push({
|
||||
advisory,
|
||||
matchedSpecifier: specifier,
|
||||
isHighRisk: advisoryLooksHighRisk(advisory),
|
||||
});
|
||||
break; // Only count each advisory once
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate strings from an array.
|
||||
*/
|
||||
export function uniqueStrings(arr: string[]): string[] {
|
||||
return Array.from(new Set(arr));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shared advisory risk evaluation for NanoClaw host + MCP layers.
|
||||
*/
|
||||
|
||||
export type SkillSafetyRecommendation = 'install' | 'block' | 'review';
|
||||
|
||||
export interface AdvisoryRiskInput {
|
||||
severity?: string;
|
||||
type?: string;
|
||||
action?: string;
|
||||
exploitability_score?: string;
|
||||
}
|
||||
|
||||
export interface AdvisoryRiskEvaluation {
|
||||
safe: boolean;
|
||||
recommendation: SkillSafetyRecommendation;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function normalizeExploitabilityScore(score: unknown): 'high' | 'medium' | 'low' | 'unknown' {
|
||||
const value = String(score || '').toLowerCase().trim();
|
||||
if (value === 'high' || value === 'medium' || value === 'low') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function evaluateAdvisoryRisk(advisories: AdvisoryRiskInput[]): AdvisoryRiskEvaluation {
|
||||
if (advisories.length === 0) {
|
||||
return { safe: true, recommendation: 'install', reason: 'No advisories found' };
|
||||
}
|
||||
|
||||
const hasMalicious = advisories.some((a) => String(a.type || '').toLowerCase().includes('malicious'));
|
||||
const hasRemoveAction = advisories.some((a) =>
|
||||
/\b(remove|uninstall|disable|quarantine|block)\b/i.test(String(a.action || ''))
|
||||
);
|
||||
const hasCritical = advisories.some((a) => String(a.severity || '').toLowerCase() === 'critical');
|
||||
const hasHigh = advisories.some((a) => String(a.severity || '').toLowerCase() === 'high');
|
||||
const hasHighExploitability = advisories.some(
|
||||
(a) => normalizeExploitabilityScore(a.exploitability_score) === 'high'
|
||||
);
|
||||
|
||||
if (hasMalicious || hasRemoveAction) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Malicious skill or removal recommended by ClawSec',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCritical && hasHighExploitability) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Critical advisory with high exploitability context - do not install',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCritical) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Critical security advisory - do not install',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHighExploitability) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'High exploitability advisory - urgent user review strongly recommended',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHigh) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'High severity advisory - user review strongly recommended',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'Advisory found - review details before installing',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Ed25519 Signature Verification for NanoClaw
|
||||
* Ported from ClawSec's feed.mjs
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import { ChecksumsManifest } from './types.js';
|
||||
|
||||
/**
|
||||
* Allowed domains for feed/signature fetching.
|
||||
* Only connections to these domains are permitted for security.
|
||||
*/
|
||||
const ALLOWED_DOMAINS = [
|
||||
'clawsec.prompt.security',
|
||||
'prompt.security',
|
||||
'raw.githubusercontent.com',
|
||||
'github.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom error class for security policy violations.
|
||||
* These errors should always propagate and never be silently caught.
|
||||
*/
|
||||
export class SecurityPolicyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecurityPolicyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
|
||||
*/
|
||||
function createSecureAgent(): https.Agent {
|
||||
return new https.Agent({
|
||||
// Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1)
|
||||
minVersion: 'TLSv1.2',
|
||||
// Ensure certificate validation is enabled (reject unauthorized certificates)
|
||||
rejectUnauthorized: true,
|
||||
// Use strong cipher suites
|
||||
ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL is from an allowed domain.
|
||||
*/
|
||||
function isAllowedDomain(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow HTTPS protocol
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Check if hostname matches any allowed domain
|
||||
return ALLOWED_DOMAINS.some(
|
||||
(allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure wrapper around fetch with TLS enforcement and domain validation.
|
||||
*/
|
||||
export async function secureFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
// Validate domain before making request
|
||||
if (!isAllowedDomain(url)) {
|
||||
throw new SecurityPolicyError(
|
||||
`Security policy violation: URL domain not allowed. ` +
|
||||
`Only connections to ${ALLOWED_DOMAINS.join(', ')} are permitted. ` +
|
||||
`Blocked: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
// Use secure HTTPS agent with TLS 1.2+ enforcement
|
||||
const agent = createSecureAgent();
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
// @ts-expect-error - agent is supported in Node.js fetch
|
||||
agent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a signature from various formats (base64 string or JSON).
|
||||
*/
|
||||
function decodeSignature(signatureRaw: string): Buffer | null {
|
||||
const trimmed = signatureRaw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let encoded = trimmed;
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && typeof parsed.signature === 'string') {
|
||||
encoded = parsed.signature;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = encoded.replace(/\s+/g, '');
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
return Buffer.from(normalized, 'base64');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an Ed25519 signature for a payload.
|
||||
*/
|
||||
export function verifySignedPayload(
|
||||
payloadRaw: string,
|
||||
signatureRaw: string,
|
||||
publicKeyPem: string
|
||||
): boolean {
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
if (!signature) return false;
|
||||
|
||||
const keyPem = publicKeyPem.trim();
|
||||
if (!keyPem) return false;
|
||||
|
||||
try {
|
||||
const publicKey = crypto.createPublicKey(keyPem);
|
||||
return crypto.verify(null, Buffer.from(payloadRaw, 'utf8'), publicKey, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of content.
|
||||
*/
|
||||
export function sha256Hex(content: string | Buffer): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of a file.
|
||||
* Convenience wrapper for file-based integrity monitoring and package verification.
|
||||
*/
|
||||
export function sha256File(filePath: string): string {
|
||||
const data = fs.readFileSync(filePath);
|
||||
return sha256Hex(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates an Ed25519 public key from PEM format.
|
||||
* @throws {SecurityPolicyError} if PEM format is invalid
|
||||
*/
|
||||
export function loadPublicKey(pemString: string): crypto.KeyObject {
|
||||
const trimmed = pemString.trim();
|
||||
if (!trimmed.startsWith('-----BEGIN PUBLIC KEY-----')) {
|
||||
throw new SecurityPolicyError('Invalid PEM format: must start with -----BEGIN PUBLIC KEY-----');
|
||||
}
|
||||
|
||||
try {
|
||||
return crypto.createPublicKey(trimmed);
|
||||
} catch (error) {
|
||||
throw new SecurityPolicyError(
|
||||
`Failed to load public key: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies Ed25519 detached signature for a file.
|
||||
* Matches the API of verify_detached_ed25519.mjs from OpenClaw.
|
||||
*
|
||||
* @param dataPath - Path to the file to verify
|
||||
* @param signaturePath - Path to the detached signature file (.sig)
|
||||
* @param publicKeyPem - Ed25519 public key in PEM format
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export function verifyDetachedSignature(
|
||||
dataPath: string,
|
||||
signaturePath: string,
|
||||
publicKeyPem: string
|
||||
): boolean {
|
||||
try {
|
||||
const data = fs.readFileSync(dataPath);
|
||||
const signatureRaw = fs.readFileSync(signaturePath, 'utf8');
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
|
||||
if (!signature) return false;
|
||||
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem.trim());
|
||||
return crypto.verify(null, data, publicKey, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies detached signature with detailed error information.
|
||||
* Useful for debugging signature verification failures.
|
||||
*
|
||||
* @param dataPath - Path to the file to verify
|
||||
* @param signaturePath - Path to the detached signature file (.sig)
|
||||
* @param publicKeyPem - Ed25519 public key in PEM format
|
||||
* @returns Object with valid flag and optional error message
|
||||
*/
|
||||
export function verifyDetachedSignatureWithDetails(
|
||||
dataPath: string,
|
||||
signaturePath: string,
|
||||
publicKeyPem: string
|
||||
): { valid: boolean; error?: string } {
|
||||
try {
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
return { valid: false, error: 'Data file not found' };
|
||||
}
|
||||
if (!fs.existsSync(signaturePath)) {
|
||||
return { valid: false, error: 'Signature file not found' };
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(dataPath);
|
||||
const signatureRaw = fs.readFileSync(signaturePath, 'utf8');
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
|
||||
if (!signature) {
|
||||
return { valid: false, error: 'Invalid signature format' };
|
||||
}
|
||||
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem.trim());
|
||||
const valid = crypto.verify(null, data, publicKey, signature);
|
||||
|
||||
return { valid, error: valid ? undefined : 'Signature verification failed' };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Verification error: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies multiple files against expected hashes.
|
||||
* Returns list of files that don't match their expected hashes.
|
||||
*
|
||||
* @param files - Map of file paths to expected SHA-256 hashes
|
||||
* @returns Array of mismatches with path, expected, and actual hashes
|
||||
*/
|
||||
export function verifyFileHashes(
|
||||
files: Record<string, string>
|
||||
): { path: string; expected: string; actual: string }[] {
|
||||
const mismatches = [];
|
||||
|
||||
for (const [path, expectedHash] of Object.entries(files)) {
|
||||
try {
|
||||
const actualHash = sha256File(path);
|
||||
if (actualHash !== expectedHash) {
|
||||
mismatches.push({ path, expected: expectedHash, actual: actualHash });
|
||||
}
|
||||
} catch (error) {
|
||||
// File missing or unreadable
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: expectedHash,
|
||||
actual: `ERROR: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts SHA-256 value from various formats.
|
||||
*/
|
||||
function extractSha256Value(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && 'sha256' in value) {
|
||||
const sha256 = (value as { sha256: unknown }).sha256;
|
||||
if (typeof sha256 === 'string') {
|
||||
const normalized = sha256.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a checksums manifest JSON.
|
||||
*/
|
||||
export function parseChecksumsManifest(manifestRaw: string): ChecksumsManifest {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(manifestRaw);
|
||||
} catch {
|
||||
throw new Error('Checksum manifest is not valid JSON');
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('Checksum manifest must be an object');
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
const algorithmRaw = typeof obj.algorithm === 'string' ? obj.algorithm.trim().toLowerCase() : 'sha256';
|
||||
if (algorithmRaw !== 'sha256') {
|
||||
throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || '(empty)'}`);
|
||||
}
|
||||
|
||||
// Support legacy manifest formats
|
||||
const schemaVersion = (
|
||||
typeof obj.schema_version === 'string' ? obj.schema_version.trim() :
|
||||
typeof obj.version === 'string' ? obj.version.trim() :
|
||||
typeof obj.generated_at === 'string' ? obj.generated_at.trim() :
|
||||
'1'
|
||||
);
|
||||
|
||||
if (!schemaVersion) {
|
||||
throw new Error('Checksum manifest missing schema_version');
|
||||
}
|
||||
|
||||
if (typeof obj.files !== 'object' || obj.files === null) {
|
||||
throw new Error('Checksum manifest missing files object');
|
||||
}
|
||||
|
||||
const files: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj.files)) {
|
||||
if (!key.trim()) continue;
|
||||
const digest = extractSha256Value(value);
|
||||
if (!digest) {
|
||||
throw new Error(`Invalid checksum digest entry for ${key}`);
|
||||
}
|
||||
files[key] = digest;
|
||||
}
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
throw new Error('Checksum manifest has no usable file digests');
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: schemaVersion,
|
||||
algorithm: 'sha256',
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a checksum entry name for matching.
|
||||
*/
|
||||
function normalizeChecksumEntryName(entryName: string): string {
|
||||
return entryName
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^(?:\.\/)+/, '')
|
||||
.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a checksum manifest entry by name.
|
||||
*/
|
||||
function resolveChecksumManifestEntry(
|
||||
files: Record<string, string>,
|
||||
entryName: string
|
||||
): { key: string; digest: string } | null {
|
||||
const normalizedEntry = normalizeChecksumEntryName(entryName);
|
||||
if (!normalizedEntry) return null;
|
||||
|
||||
// Try direct match and common variations
|
||||
const directCandidates = [
|
||||
normalizedEntry,
|
||||
normalizedEntry.split('/').pop() || '',
|
||||
`advisories/${normalizedEntry.split('/').pop() || ''}`,
|
||||
].filter((c, i, a) => c && a.indexOf(c) === i);
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (candidate in files) {
|
||||
return { key: candidate, digest: files[candidate] };
|
||||
}
|
||||
}
|
||||
|
||||
// Try basename matching
|
||||
const basename = normalizedEntry.split('/').pop() || '';
|
||||
if (!basename) return null;
|
||||
|
||||
const basenameMatches = Object.entries(files).filter(([key]) => {
|
||||
const normalizedKey = normalizeChecksumEntryName(key);
|
||||
return normalizedKey.split('/').pop() === basename;
|
||||
});
|
||||
|
||||
if (basenameMatches.length > 1) {
|
||||
throw new Error(
|
||||
`Checksum manifest entry is ambiguous for ${entryName}; ` +
|
||||
`multiple manifest keys share basename ${basename}`
|
||||
);
|
||||
}
|
||||
|
||||
if (basenameMatches.length === 1) {
|
||||
const [resolvedKey, digest] = basenameMatches[0];
|
||||
return { key: resolvedKey, digest };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies checksums for expected entries.
|
||||
*/
|
||||
export function verifyChecksums(
|
||||
manifest: ChecksumsManifest,
|
||||
expectedEntries: Record<string, string | Buffer>
|
||||
): void {
|
||||
for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
|
||||
if (!entryName) continue;
|
||||
|
||||
const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
|
||||
if (!resolved) {
|
||||
throw new Error(`Checksum manifest missing required entry: ${entryName}`);
|
||||
}
|
||||
|
||||
const actualDigest = sha256Hex(entryContent);
|
||||
if (actualDigest !== resolved.digest) {
|
||||
throw new Error(`Checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches text from a URL with timeout.
|
||||
*/
|
||||
export async function fetchText(url: string, timeoutMs: number = 10000): Promise<string | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await secureFetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json,text/plain;q=0.9,*/*;q=0.8' },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
// Re-throw security policy violations - these should never be silently caught
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
throw error;
|
||||
}
|
||||
// Network errors, timeouts, etc. return null (graceful degradation)
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default checksums URL from feed URL.
|
||||
*/
|
||||
export function defaultChecksumsUrl(feedUrl: string): string {
|
||||
try {
|
||||
return new URL('checksums.json', feedUrl).toString();
|
||||
} catch {
|
||||
const fallbackBase = feedUrl.replace(/\/?[^/]*$/, '');
|
||||
return `${fallbackBase}/checksums.json`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts the basename from a URL or file path.
|
||||
*/
|
||||
function _safeBasename(urlOrPath: string, fallback: string): string {
|
||||
try {
|
||||
const parsed = new URL(urlOrPath);
|
||||
const pathname = parsed.pathname;
|
||||
const lastSlash = pathname.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < pathname.length - 1) {
|
||||
return pathname.slice(lastSlash + 1);
|
||||
}
|
||||
} catch {
|
||||
const normalized = urlOrPath.trim();
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < normalized.length - 1) {
|
||||
return normalized.slice(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* TypeScript types for NanoClaw Skill Installer
|
||||
* Adapted from ClawSec's guarded skill installer
|
||||
*/
|
||||
|
||||
export interface Advisory {
|
||||
id: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
type: 'vulnerable_skill' | 'malicious_skill' | 'prompt_injection' | string;
|
||||
title: string;
|
||||
description: string;
|
||||
affected: string[]; // e.g., ["skill-name@1.0.0", "skill-name@1.0.1"]
|
||||
action: string;
|
||||
published: string;
|
||||
references: string[];
|
||||
cvss_score?: number;
|
||||
nvd_url?: string;
|
||||
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown';
|
||||
exploitability_rationale?: string;
|
||||
source?: string;
|
||||
github_issue_url?: string;
|
||||
reporter?: {
|
||||
agent_name?: string;
|
||||
opener_type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdvisoryFeed {
|
||||
version: string;
|
||||
updated: string;
|
||||
description: string;
|
||||
advisories: Advisory[];
|
||||
}
|
||||
|
||||
export interface AdvisoryMatch {
|
||||
advisory: Advisory;
|
||||
matchedSpecifier: string;
|
||||
isHighRisk: boolean;
|
||||
}
|
||||
|
||||
export interface ReputationResult {
|
||||
score: number; // 0-100
|
||||
warnings: string[];
|
||||
virusTotalFlags: string[];
|
||||
safe: boolean;
|
||||
}
|
||||
|
||||
export interface SkillMetadata {
|
||||
slug: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
downloads: number;
|
||||
}
|
||||
|
||||
export interface InspectSkillResult {
|
||||
skill: SkillMetadata;
|
||||
reputation: ReputationResult;
|
||||
advisories: AdvisoryMatch[];
|
||||
overallStatus: 'safe' | 'reputation_warning' | 'advisory_warning' | 'blocked';
|
||||
}
|
||||
|
||||
export interface SkillInstallRequest {
|
||||
request_id: string;
|
||||
user_jid: string;
|
||||
group_jid: string;
|
||||
skill_slug: string;
|
||||
skill_version: string | null;
|
||||
reputation_score: number;
|
||||
reputation_warnings: string[];
|
||||
advisories: AdvisoryMatch[];
|
||||
created_at: number; // Unix timestamp
|
||||
expires_at: number; // Unix timestamp
|
||||
status: 'pending' | 'confirmed' | 'expired' | 'cancelled';
|
||||
confirmed_at: number | null;
|
||||
}
|
||||
|
||||
export interface ChecksumsManifest {
|
||||
schema_version: string;
|
||||
algorithm: 'sha256';
|
||||
files: Record<string, string>; // filename -> hex digest
|
||||
}
|
||||
|
||||
export interface SignatureVerificationOptions {
|
||||
signatureUrl?: string;
|
||||
checksumsUrl?: string;
|
||||
checksumsSignatureUrl?: string;
|
||||
publicKeyPem: string;
|
||||
checksumsPublicKeyPem?: string;
|
||||
allowUnsigned?: boolean;
|
||||
verifyChecksumManifest?: boolean;
|
||||
}
|
||||
|
||||
export interface AffectedSpecifier {
|
||||
name: string;
|
||||
versionSpec: string; // e.g., "1.0.0", "^1.0.0", "*"
|
||||
}
|
||||
|
||||
// MCP Tool Request/Response Types
|
||||
|
||||
export interface InspectSkillRequest {
|
||||
slug: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RequestSkillInstallRequest {
|
||||
slug: string;
|
||||
version?: string;
|
||||
target_group_jid?: string;
|
||||
}
|
||||
|
||||
export interface RequestSkillInstallResponse {
|
||||
request_id: string;
|
||||
status: 'safe' | 'reputation_warning' | 'advisory_warning' | 'blocked';
|
||||
reputation?: ReputationResult;
|
||||
advisories?: AdvisoryMatch[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ConfirmSkillInstallRequest {
|
||||
request_id: string;
|
||||
acknowledge_reputation?: boolean;
|
||||
acknowledge_advisories?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmSkillInstallResponse {
|
||||
status: 'installed' | 'failed';
|
||||
installed_path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ListSkillsRequest {
|
||||
target_group_jid?: string;
|
||||
}
|
||||
|
||||
export interface ListSkillsResponse {
|
||||
skills: Array<{
|
||||
slug: string;
|
||||
version: string;
|
||||
installed_at: string;
|
||||
path: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RemoveSkillRequest {
|
||||
slug: string;
|
||||
target_group_jid?: string;
|
||||
}
|
||||
|
||||
export interface RemoveSkillResponse {
|
||||
status: 'removed' | 'not_found';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// IPC Task Types
|
||||
|
||||
export interface IpcSkillInstallRequest {
|
||||
type: 'skill_install_request';
|
||||
slug: string;
|
||||
version?: string;
|
||||
target_group_jid?: string;
|
||||
user_jid: string;
|
||||
group_folder: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface IpcSkillInstallConfirm {
|
||||
type: 'skill_install_confirm';
|
||||
request_id: string;
|
||||
acknowledge_reputation: boolean;
|
||||
acknowledge_advisories: boolean;
|
||||
user_jid: string;
|
||||
group_folder: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface IpcSkillRemove {
|
||||
type: 'skill_remove';
|
||||
slug: string;
|
||||
target_group_jid?: string;
|
||||
user_jid: string;
|
||||
group_folder: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Database Schema
|
||||
|
||||
export interface SkillInstallRequestRow {
|
||||
request_id: string;
|
||||
user_jid: string;
|
||||
group_jid: string;
|
||||
skill_slug: string;
|
||||
skill_version: string | null;
|
||||
reputation_score: number;
|
||||
reputation_warnings_json: string; // JSON array
|
||||
advisories_json: string; // JSON array
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
status: 'pending' | 'confirmed' | 'expired' | 'cancelled';
|
||||
confirmed_at: number | null;
|
||||
}
|
||||
|
||||
export interface InstalledSkillRow {
|
||||
slug: string;
|
||||
version: string;
|
||||
installed_at: string;
|
||||
installed_by: string; // user_jid
|
||||
path: string;
|
||||
metadata_json: string; // SkillMetadata as JSON
|
||||
}
|
||||
|
||||
// Skill Signature Verification Types (Phase 1)
|
||||
|
||||
/**
|
||||
* IPC request for skill signature verification
|
||||
*/
|
||||
export interface VerifySkillSignatureRequest {
|
||||
type: 'verify_skill_signature';
|
||||
requestId: string;
|
||||
groupFolder: string;
|
||||
timestamp: string;
|
||||
packagePath: string;
|
||||
signaturePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC response for skill signature verification
|
||||
*/
|
||||
export interface VerifySkillSignatureResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: {
|
||||
valid: boolean;
|
||||
signer: string; // 'clawsec' or custom signer identifier
|
||||
packageHash: string; // SHA-256 of package
|
||||
verifiedAt: string; // ISO timestamp
|
||||
algorithm: 'Ed25519';
|
||||
};
|
||||
error?: {
|
||||
code: 'SIGNATURE_INVALID' | 'FILE_NOT_FOUND' | 'CRYPTO_ERROR' | 'SERVICE_UNAVAILABLE';
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP tool parameters for package verification
|
||||
*/
|
||||
export interface VerifySkillPackageParams {
|
||||
packagePath: string;
|
||||
signaturePath?: string; // Optional: auto-detects .sig if omitted
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* ClawSec Advisory Feed MCP Tools for NanoClaw
|
||||
*
|
||||
* Add these tools to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
*
|
||||
* These tools run in the container context and read from the host-managed
|
||||
* advisory cache at /workspace/project/data/clawsec-advisory-cache.json
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { evaluateAdvisoryRisk, normalizeExploitabilityScore } from '../lib/risk.js';
|
||||
import { matchesAffectedSpecifier } from '../lib/advisories.js';
|
||||
|
||||
// These variables are provided by the host environment (ipc-mcp-stdio.ts)
|
||||
// when this code is integrated into the NanoClaw container agent.
|
||||
declare const server: { tool: (...args: any[]) => void };
|
||||
declare function writeIpcFile(dir: string, data: any): void;
|
||||
declare const TASKS_DIR: string;
|
||||
declare const groupFolder: string;
|
||||
const CACHE_FILE = '/workspace/project/data/clawsec-advisory-cache.json';
|
||||
|
||||
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
const exploitabilityOrder: Record<string, number> = { high: 0, medium: 1, low: 2, unknown: 3 };
|
||||
|
||||
/**
|
||||
* Discover installed skills in a directory
|
||||
*/
|
||||
async function discoverInstalledSkills(installRoot: string): Promise<Array<{
|
||||
name: string;
|
||||
version: string | null;
|
||||
dirName: string;
|
||||
}>> {
|
||||
const skills: Array<{ name: string; version: string | null; dirName: string }> = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(installRoot, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const skillJsonPath = path.join(installRoot, entry.name, 'skill.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(skillJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
skills.push({
|
||||
name: parsed.name || entry.name,
|
||||
version: parsed.version || null,
|
||||
dirName: entry.name,
|
||||
});
|
||||
} catch {
|
||||
// Skill without skill.json, use directory name
|
||||
skills.push({
|
||||
name: entry.name,
|
||||
version: null,
|
||||
dirName: entry.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Return empty if directory doesn't exist
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find advisory matches for installed skills
|
||||
*/
|
||||
function findAdvisoryMatches(
|
||||
advisories: any[],
|
||||
skills: Array<{ name: string; version: string | null; dirName: string }>
|
||||
): Array<{
|
||||
advisory: any;
|
||||
skill: { name: string; version: string | null; dirName: string };
|
||||
matchedAffected: string[];
|
||||
}> {
|
||||
const matches: Array<{
|
||||
advisory: any;
|
||||
skill: { name: string; version: string | null; dirName: string };
|
||||
matchedAffected: string[];
|
||||
}> = [];
|
||||
|
||||
for (const advisory of advisories) {
|
||||
for (const skill of skills) {
|
||||
const matchedAffected: string[] = [];
|
||||
|
||||
for (const affected of advisory.affected || []) {
|
||||
if (matchesAffectedSpecifier(affected, skill.name, skill.version, skill.dirName)) {
|
||||
matchedAffected.push(affected);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedAffected.length > 0) {
|
||||
matches.push({ advisory, skill, matchedAffected });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Add these tools to the server:
|
||||
|
||||
server.tool(
|
||||
'clawsec_check_advisories',
|
||||
'Check ClawSec advisory feed for security issues affecting installed skills. Returns list of matching advisories with details. Use this to scan for known vulnerabilities, malicious skills, or deprecated packages.',
|
||||
{
|
||||
installRoot: z.string().optional().describe('Skills installation directory (default: ~/.claude/skills)'),
|
||||
forceRefresh: z.boolean().optional().describe('Force cache refresh before checking (causes 1-2 second delay)'),
|
||||
},
|
||||
async (args) => {
|
||||
// Request cache refresh if needed
|
||||
if (args.forceRefresh) {
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'refresh_advisory_cache',
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// Wait for refresh (async, best-effort)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
// Read cache from shared mount
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
const installRoot = args.installRoot || path.join(process.env.HOME || '~', '.claude', 'skills');
|
||||
|
||||
// Discover installed skills
|
||||
const skills = await discoverInstalledSkills(installRoot);
|
||||
|
||||
// Find matches
|
||||
const matches = findAdvisoryMatches(cacheData.feed.advisories, skills);
|
||||
|
||||
// Calculate cache age
|
||||
const cacheAge = Date.now() - Date.parse(cacheData.fetchedAt);
|
||||
const cacheAgeMinutes = Math.floor(cacheAge / 60000);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
feedUpdated: cacheData.feed.updated || null,
|
||||
totalAdvisories: cacheData.feed.advisories.length,
|
||||
installedSkills: skills.length,
|
||||
matches: matches.map(m => ({
|
||||
advisory: {
|
||||
id: m.advisory.id,
|
||||
severity: m.advisory.severity,
|
||||
type: m.advisory.type,
|
||||
title: m.advisory.title,
|
||||
description: m.advisory.description,
|
||||
action: m.advisory.action,
|
||||
published: m.advisory.published,
|
||||
exploitability_score: normalizeExploitabilityScore(m.advisory.exploitability_score),
|
||||
exploitability_rationale: m.advisory.exploitability_rationale || null,
|
||||
},
|
||||
skill: m.skill,
|
||||
matchedAffected: m.matchedAffected,
|
||||
})),
|
||||
cacheAge: `${cacheAgeMinutes} minutes`,
|
||||
cacheTimestamp: cacheData.fetchedAt,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to check advisories: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'clawsec_check_skill_safety',
|
||||
'Check if a specific skill is safe to install based on ClawSec advisory feed. Returns safety recommendation (install/block/review) with reasons. Use this as a pre-install gate before installing any skill.',
|
||||
{
|
||||
skillName: z.string().describe('Name of skill to check'),
|
||||
skillVersion: z.string().optional().describe('Version of skill (optional, for version-specific checks)'),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
|
||||
// Find matching advisories for this skill
|
||||
const matchingAdvisories = cacheData.feed.advisories.filter((advisory: any) =>
|
||||
advisory.affected.some((affected: string) => {
|
||||
return matchesAffectedSpecifier(affected, args.skillName, args.skillVersion || null);
|
||||
})
|
||||
);
|
||||
|
||||
if (matchingAdvisories.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
safe: true,
|
||||
advisories: [],
|
||||
recommendation: 'install',
|
||||
reason: 'No known advisories for this skill',
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const risk = evaluateAdvisoryRisk(matchingAdvisories);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
safe: risk.safe,
|
||||
advisories: matchingAdvisories.map((a: any) => ({
|
||||
id: a.id,
|
||||
severity: a.severity,
|
||||
type: a.type,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
action: a.action,
|
||||
published: a.published,
|
||||
affected: a.affected,
|
||||
exploitability_score: normalizeExploitabilityScore(a.exploitability_score),
|
||||
exploitability_rationale: a.exploitability_rationale || null,
|
||||
})),
|
||||
recommendation: risk.recommendation,
|
||||
reason: risk.reason,
|
||||
skillName: args.skillName,
|
||||
skillVersion: args.skillVersion || null,
|
||||
advisoryCount: matchingAdvisories.length,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
// Conservative: block on error
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
safe: false,
|
||||
advisories: [],
|
||||
recommendation: 'review',
|
||||
reason: `Failed to verify safety: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error: true,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'clawsec_list_advisories',
|
||||
'List ClawSec advisories with optional filtering. Use this to browse security advisories, filter by severity/type/exploitability, or search for specific affected skills.',
|
||||
{
|
||||
severity: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('Filter by severity level'),
|
||||
type: z.string().optional().describe('Filter by advisory type (for example: vulnerable_skill, malicious_skill, prompt_injection)'),
|
||||
exploitabilityScore: z.enum(['high', 'medium', 'low', 'unknown']).optional()
|
||||
.describe('Filter by exploitability score'),
|
||||
affectedSkill: z.string().optional().describe('Filter by affected skill name (partial match supported)'),
|
||||
limit: z.number().optional().describe('Maximum number of results (default: unlimited)'),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
let advisories = [...cacheData.feed.advisories];
|
||||
|
||||
// Apply filters
|
||||
if (args.severity) {
|
||||
advisories = advisories.filter((a: any) => a.severity === args.severity);
|
||||
}
|
||||
if (args.type) {
|
||||
const typeFilter = String(args.type).toLowerCase().trim();
|
||||
advisories = advisories.filter((a: any) => String(a.type || '').toLowerCase().trim() === typeFilter);
|
||||
}
|
||||
if (args.exploitabilityScore) {
|
||||
advisories = advisories.filter(
|
||||
(a: any) => normalizeExploitabilityScore(a.exploitability_score) === args.exploitabilityScore
|
||||
);
|
||||
}
|
||||
if (args.affectedSkill) {
|
||||
advisories = advisories.filter((a: any) =>
|
||||
a.affected.some((spec: string) => spec.includes(args.affectedSkill!))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by exploitability first, then severity, then publish date (newest first).
|
||||
advisories.sort((a: any, b: any) => {
|
||||
const exploitabilityDiff =
|
||||
(exploitabilityOrder[normalizeExploitabilityScore(a.exploitability_score)] ?? 999) -
|
||||
(exploitabilityOrder[normalizeExploitabilityScore(b.exploitability_score)] ?? 999);
|
||||
if (exploitabilityDiff !== 0) return exploitabilityDiff;
|
||||
|
||||
const severityDiff = (severityOrder[a.severity] || 999) - (severityOrder[b.severity] || 999);
|
||||
if (severityDiff !== 0) return severityDiff;
|
||||
return (b.published || '').localeCompare(a.published || '');
|
||||
});
|
||||
|
||||
// Apply limit
|
||||
const originalCount = advisories.length;
|
||||
if (args.limit && args.limit > 0) {
|
||||
advisories = advisories.slice(0, args.limit);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
feedUpdated: cacheData.feed.updated || null,
|
||||
advisories: advisories.map((a: any) => ({
|
||||
id: a.id,
|
||||
severity: a.severity,
|
||||
type: a.type,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
action: a.action,
|
||||
published: a.published,
|
||||
affected: a.affected,
|
||||
exploitability_score: normalizeExploitabilityScore(a.exploitability_score),
|
||||
exploitability_rationale: a.exploitability_rationale || null,
|
||||
})),
|
||||
total: cacheData.feed.advisories.length,
|
||||
filtered: originalCount,
|
||||
returned: advisories.length,
|
||||
filters: {
|
||||
severity: args.severity || null,
|
||||
type: args.type || null,
|
||||
exploitabilityScore: args.exploitabilityScore || null,
|
||||
affectedSkill: args.affectedSkill || null,
|
||||
limit: args.limit || null,
|
||||
},
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to list advisories: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'clawsec_refresh_cache',
|
||||
'Request immediate refresh of the advisory cache from ClawSec feed. This fetches the latest advisories and verifies signatures. Use when you need up-to-date advisory information.',
|
||||
{},
|
||||
async () => {
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'refresh_advisory_cache',
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: 'Advisory cache refresh requested. This may take a few seconds. Check status with clawsec_check_advisories.',
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* ClawSec File Integrity Monitoring MCP Tools for NanoClaw
|
||||
*
|
||||
* Add these tools to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
*
|
||||
* These tools run in the container context and communicate with the host-side
|
||||
* integrity monitor via IPC.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
// These variables are provided by the host environment (ipc-mcp-stdio.ts)
|
||||
// when this code is integrated into the NanoClaw container agent.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare const server: { tool: (...args: any[]) => void };
|
||||
declare function writeIpcFile(dir: string, data: any): void;
|
||||
declare const TASKS_DIR: string;
|
||||
declare const groupFolder: string;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// Result waiting helper
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function waitForResult(requestId: string, timeoutMs: number = 60000): Promise<any> {
|
||||
const resultDir = '/workspace/ipc/clawsec_results';
|
||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
||||
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (fs.existsSync(resultPath)) {
|
||||
const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
|
||||
fs.unlinkSync(resultPath); // Cleanup
|
||||
return result;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Poll every 1s
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for result: ${requestId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool 1: clawsec_check_integrity
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_check_integrity',
|
||||
'Check protected files for unauthorized changes (drift). Automatically restores critical files to approved baselines. Use this for scheduled integrity monitoring or manual security checks.',
|
||||
{
|
||||
mode: z.enum(['check', 'status']).optional().describe('check=detect drift and restore, status=view baselines only (default: check)'),
|
||||
autoRestore: z.boolean().optional().describe('Auto-restore files in restore mode (default: true)'),
|
||||
},
|
||||
async (args) => {
|
||||
const requestId = `integrity-check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Write IPC request
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'integrity_check',
|
||||
requestId,
|
||||
mode: args.mode || 'check',
|
||||
autoRestore: args.autoRestore !== false,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for result
|
||||
const result = await waitForResult(requestId, 60000);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
isError: !result.success
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Integrity check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool 2: clawsec_approve_change
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_approve_change',
|
||||
'Approve an intentional file modification as the new approved baseline. Use this after making legitimate changes to protected files (e.g., updating CLAUDE.md or registered_groups.json).',
|
||||
{
|
||||
path: z.string().describe('Absolute path to file to approve (e.g., /workspace/group/CLAUDE.md)'),
|
||||
note: z.string().optional().describe('Optional note explaining why this change is being approved'),
|
||||
},
|
||||
async (args) => {
|
||||
const requestId = `integrity-approve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Write IPC request
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'integrity_approve',
|
||||
requestId,
|
||||
path: args.path,
|
||||
note: args.note || '',
|
||||
approvedBy: 'agent', // In production, should be user JID
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForResult(requestId, 30000);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
isError: !result.success
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Approve failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool 3: clawsec_integrity_status
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_integrity_status',
|
||||
'View current baseline status for protected files without checking for drift. Use this to see what files are monitored, when baselines were created, and their current hashes.',
|
||||
{
|
||||
path: z.string().optional().describe('Optional: specific file path to check. If omitted, shows all protected files.'),
|
||||
},
|
||||
async (args) => {
|
||||
const requestId = `integrity-status-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'integrity_status',
|
||||
requestId,
|
||||
path: args.path,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForResult(requestId, 30000);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
isError: !result.success
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Status check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool 4: clawsec_verify_audit
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_verify_audit',
|
||||
'Verify the integrity of the audit log hash chain. Use this to detect if the audit log has been tampered with. A valid chain proves all logged events are authentic.',
|
||||
{},
|
||||
async () => {
|
||||
const requestId = `integrity-verify-audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'integrity_verify_audit',
|
||||
requestId,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForResult(requestId, 30000);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
isError: !result.success
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Audit verification failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Usage Examples (for documentation)
|
||||
// ============================================================================
|
||||
|
||||
// Usage Examples (for documentation):
|
||||
//
|
||||
// Example 1: Scheduled Integrity Check
|
||||
//
|
||||
// schedule_task({
|
||||
// prompt: 'Check file integrity with clawsec_check_integrity...',
|
||||
// schedule_type: 'cron',
|
||||
// schedule_value: '0,30 * * * *', // Every 30 minutes
|
||||
// context_mode: 'isolated'
|
||||
// });
|
||||
//
|
||||
// Example 2: Pre-Deployment Check
|
||||
//
|
||||
// const check = await tools.clawsec_check_integrity({ mode: 'check', autoRestore: false });
|
||||
// if (check.drift_detected) { ... }
|
||||
//
|
||||
// Example 3: Approve Legitimate Changes
|
||||
//
|
||||
// await tools.clawsec_approve_change({
|
||||
// path: '/workspace/group/CLAUDE.md',
|
||||
// note: 'Updated agent instructions to include new skill'
|
||||
// });
|
||||
//
|
||||
// Example 4: Audit Verification
|
||||
//
|
||||
// const audit = await tools.clawsec_verify_audit();
|
||||
// if (!audit.valid) { ... }
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* ClawSec Skill Signature Verification MCP Tool for NanoClaw
|
||||
*
|
||||
* Add this tool to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
*
|
||||
* This tool verifies Ed25519 signatures on skill packages to prevent supply chain attacks.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
// These variables are provided by the host environment (ipc-mcp-stdio.ts)
|
||||
// when this code is integrated into the NanoClaw container agent.
|
||||
declare const server: { tool: (...args: any[]) => void };
|
||||
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';
|
||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
||||
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (fs.existsSync(resultPath)) {
|
||||
const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
|
||||
fs.unlinkSync(resultPath); // Cleanup
|
||||
return result;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // Poll every 100ms
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for result: ${requestId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool: clawsec_verify_skill_package
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_verify_skill_package',
|
||||
'Verify Ed25519 signature of a skill package before installation. Prevents installation of tampered or malicious skill packages by checking ClawSec signatures.',
|
||||
{
|
||||
packagePath: z.string().describe('Absolute path to skill package (.tar.gz or .zip)'),
|
||||
signaturePath: z.string().optional().describe('Path to signature file. If omitted, auto-detects <packagePath>.sig'),
|
||||
},
|
||||
async (args: { packagePath: string; signaturePath?: string }) => {
|
||||
const requestId = `verify-signature-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let packagePath: string;
|
||||
let sigPath: string;
|
||||
|
||||
try {
|
||||
packagePath = validatePackagePath(args.packagePath);
|
||||
sigPath = validateSignaturePath(args.signaturePath || `${packagePath}.sig`);
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
valid: false,
|
||||
recommendation: 'block',
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// Write IPC request to host
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'verify_skill_signature',
|
||||
requestId,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
packagePath,
|
||||
signaturePath: sigPath,
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for host to verify (5 second timeout)
|
||||
const result = await waitForResult(requestId, 5000);
|
||||
|
||||
if (!result.success) {
|
||||
// Service error or file not found
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
valid: false,
|
||||
recommendation: 'block',
|
||||
packagePath,
|
||||
signaturePath: sigPath,
|
||||
error: result.message || 'Verification failed',
|
||||
reason: result.error?.code || 'UNKNOWN_ERROR'
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if signature is valid
|
||||
if (!result.data?.valid) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
valid: false,
|
||||
recommendation: 'block',
|
||||
packagePath,
|
||||
signaturePath: sigPath,
|
||||
reason: result.data?.error || 'Signature verification failed',
|
||||
packageInfo: {
|
||||
sha256: result.data?.packageHash || 'unknown'
|
||||
}
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Signature valid!
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
valid: true,
|
||||
recommendation: 'install',
|
||||
packagePath,
|
||||
signaturePath: sigPath,
|
||||
signer: result.data.signer,
|
||||
algorithm: result.data.algorithm,
|
||||
verifiedAt: result.data.verifiedAt,
|
||||
packageInfo: {
|
||||
size: fs.statSync(packagePath).size,
|
||||
sha256: result.data.packageHash
|
||||
}
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
valid: false,
|
||||
recommendation: 'block',
|
||||
error: `Verification timeout or error: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"name": "clawsec-nanoclaw",
|
||||
"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",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
"keywords": [
|
||||
"security",
|
||||
"nanoclaw",
|
||||
"whatsapp-bot",
|
||||
"mcp-tools",
|
||||
"advisory",
|
||||
"feed",
|
||||
"threat-intel",
|
||||
"containers",
|
||||
"signature-verification",
|
||||
"vulnerability-scanning",
|
||||
"agents",
|
||||
"ai"
|
||||
],
|
||||
"platform": "nanoclaw",
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "NanoClaw skill documentation"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and release notes"
|
||||
},
|
||||
{
|
||||
"path": "INSTALL.md",
|
||||
"required": true,
|
||||
"description": "Installation guide for NanoClaw deployments"
|
||||
},
|
||||
{
|
||||
"path": "mcp-tools/advisory-tools.ts",
|
||||
"required": true,
|
||||
"description": "MCP tools for advisory checking in container context"
|
||||
},
|
||||
{
|
||||
"path": "host-services/advisory-cache.ts",
|
||||
"required": true,
|
||||
"description": "Host-side advisory cache manager with periodic feed fetching"
|
||||
},
|
||||
{
|
||||
"path": "host-services/ipc-handlers.ts",
|
||||
"required": true,
|
||||
"description": "IPC handlers for MCP tool requests"
|
||||
},
|
||||
{
|
||||
"path": "lib/signatures.ts",
|
||||
"required": true,
|
||||
"description": "Ed25519 signature verification utilities"
|
||||
},
|
||||
{
|
||||
"path": "lib/advisories.ts",
|
||||
"required": true,
|
||||
"description": "Advisory matching and vulnerability detection"
|
||||
},
|
||||
{
|
||||
"path": "lib/types.ts",
|
||||
"required": true,
|
||||
"description": "TypeScript type definitions"
|
||||
},
|
||||
{
|
||||
"path": "lib/risk.ts",
|
||||
"required": true,
|
||||
"description": "Shared advisory risk evaluation logic for host and MCP tools"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed-signing-public.pem",
|
||||
"required": true,
|
||||
"description": "Pinned Ed25519 public key for feed signature verification"
|
||||
},
|
||||
{
|
||||
"path": "mcp-tools/signature-verification.ts",
|
||||
"required": true,
|
||||
"description": "Phase 1: MCP tool for skill package signature verification"
|
||||
},
|
||||
{
|
||||
"path": "host-services/skill-signature-handler.ts",
|
||||
"required": true,
|
||||
"description": "Phase 1: Host-side signature verification service"
|
||||
},
|
||||
{
|
||||
"path": "docs/SKILL_SIGNING.md",
|
||||
"required": true,
|
||||
"description": "Phase 1: Documentation for skill signing and verification"
|
||||
},
|
||||
{
|
||||
"path": "mcp-tools/integrity-tools.ts",
|
||||
"required": true,
|
||||
"description": "Phase 2: MCP tools for file integrity monitoring"
|
||||
},
|
||||
{
|
||||
"path": "host-services/integrity-handler.ts",
|
||||
"required": true,
|
||||
"description": "Phase 2: Host-side integrity monitoring service"
|
||||
},
|
||||
{
|
||||
"path": "guardian/integrity-monitor.ts",
|
||||
"required": true,
|
||||
"description": "Phase 2: Core file integrity monitoring engine"
|
||||
},
|
||||
{
|
||||
"path": "guardian/policy.json",
|
||||
"required": true,
|
||||
"description": "Phase 2: NanoClaw-specific file protection policy"
|
||||
},
|
||||
{
|
||||
"path": "docs/INTEGRITY.md",
|
||||
"required": true,
|
||||
"description": "Phase 2: Documentation for file integrity monitoring"
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"Advisory feed monitoring from clawsec.prompt.security",
|
||||
"MCP tools for agent-initiated vulnerability scans",
|
||||
"Exploitability-aware advisory prioritization for agent environments",
|
||||
"Pre-installation skill safety checks",
|
||||
"Ed25519 signature verification for advisory feeds",
|
||||
"Platform metadata preserved in advisory records for downstream filtering",
|
||||
"Containerized agent support with IPC communication"
|
||||
],
|
||||
"nanoclaw": {
|
||||
"mcp_tools": [
|
||||
"clawsec_check_advisories",
|
||||
"clawsec_check_skill_safety",
|
||||
"clawsec_list_advisories",
|
||||
"clawsec_refresh_cache",
|
||||
"clawsec_verify_skill_package",
|
||||
"clawsec_check_integrity",
|
||||
"clawsec_approve_change",
|
||||
"clawsec_integrity_status",
|
||||
"clawsec_verify_audit"
|
||||
],
|
||||
"requires": {
|
||||
"node": ">=18.0.0",
|
||||
"nanoclaw": ">=0.1.0"
|
||||
},
|
||||
"integration": {
|
||||
"mcp_tools_file": "container/agent-runner/src/ipc-mcp-stdio.ts",
|
||||
"ipc_handlers_file": "src/ipc.ts",
|
||||
"cache_location": "/workspace/project/data/clawsec-advisory-cache.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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 : [""];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
Executable
+288
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user