Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,2 +1,2 @@
|
||||
ruff==0.15.1
|
||||
ruff==0.15.2
|
||||
bandit==1.9.3
|
||||
|
||||
@@ -3,8 +3,7 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
@@ -31,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:
|
||||
@@ -63,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: '.'
|
||||
@@ -71,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: '.'
|
||||
@@ -101,6 +101,8 @@ jobs:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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
|
||||
@@ -109,6 +111,10 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -120,7 +126,11 @@ jobs:
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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
|
||||
@@ -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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
@@ -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@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -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"
|
||||
@@ -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/
|
||||
|
||||
@@ -5,6 +5,8 @@ ClawSec combines a Vite + React frontend with security skill packages and releas
|
||||
- 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/`
|
||||
@@ -15,7 +17,9 @@ ClawSec combines a Vite + React frontend with security skill packages and releas
|
||||
- `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.
|
||||
@@ -31,6 +35,7 @@ ClawSec combines a Vite + React frontend with security skill packages and releas
|
||||
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
|
||||
@@ -39,6 +44,7 @@ There is no root `npm test`; tests are mostly skill-local.
|
||||
- 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,7 +37,7 @@ ClawSec is a **complete security skill suite for AI agent platforms**. It provid
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
- **OpenClaw** (Moltbot, Clawdbot, and clones) - Full suite with skill installer, file integrity protection, and security audits
|
||||
- **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
|
||||
@@ -51,26 +51,48 @@ ClawSec is a **complete security skill suite for AI agent platforms**. It provid
|
||||
|
||||
---
|
||||
|
||||
## 🎬 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
|
||||
|
||||
@@ -142,13 +164,13 @@ The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and
|
||||
| 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
|
||||
|
||||
@@ -170,6 +192,9 @@ 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:
|
||||
@@ -178,6 +203,17 @@ The feed polls CVEs related to:
|
||||
- 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:**
|
||||
@@ -192,6 +228,8 @@ The feed polls CVEs related to:
|
||||
"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"
|
||||
}
|
||||
@@ -215,7 +253,7 @@ The feed polls CVEs related to:
|
||||
```
|
||||
|
||||
**Platform values:**
|
||||
- `"openclaw"` - OpenClaw/ClawdBot/MoltBot only
|
||||
- `"openclaw"` - OpenClaw/Clawdbot/MoltBot only
|
||||
- `"nanoclaw"` - NanoClaw only
|
||||
- `["openclaw", "nanoclaw"]` - Both platforms
|
||||
- (empty/missing) - All platforms (backward compatible)
|
||||
@@ -230,10 +268,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
|
||||
|
||||
@@ -244,7 +285,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
|
||||
@@ -295,8 +336,8 @@ Each skill release includes:
|
||||
### Signing Operations Documentation
|
||||
|
||||
For feed/release signing rollout and operations guidance:
|
||||
- [`docs/SECURITY-SIGNING.md`](docs/SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
|
||||
- [`docs/MIGRATION-SIGNED-FEED.md`](docs/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
|
||||
|
||||
---
|
||||
|
||||
@@ -357,8 +398,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
|
||||
@@ -374,24 +425,34 @@ 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-feed/ # 📡 Advisory feed skill
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -419,6 +480,14 @@ 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
|
||||
|
||||
@@ -1 +1 @@
|
||||
Rs++ntJvBvX4zVTJ/DsrfXOQG3VTUc2x4esSURSMonesmYzSm9U9kd3rBz5d+DemJOVJ/esH21VACpdE+T34AA==
|
||||
SJ1weYVVi723M8f6s8es6rg34CSPKxbvlBy1QIXdS0giskd5KTADTDLr2STqUCuWpaV7U+JQa/1eWqNX2oJ+Aw==
|
||||
@@ -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 =
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -113,6 +113,6 @@ export default [
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js', 'public/']
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js', 'public/', '.venv/']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
"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.564.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": {
|
||||
@@ -26,6 +30,7 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"fast-check": "^4.5.3",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
@@ -33,6 +38,6 @@
|
||||
"ajv": "6.14.0",
|
||||
"balanced-match": "4.0.3",
|
||||
"brace-expansion": "5.0.2",
|
||||
"minimatch": "10.2.1"
|
||||
"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,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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 970 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
Rs++ntJvBvX4zVTJ/DsrfXOQG3VTUc2x4esSURSMonesmYzSm9U9kd3rBz5d+DemJOVJ/esH21VACpdE+T34AA==
|
||||
SJ1weYVVi723M8f6s8es6rg34CSPKxbvlBy1QIXdS0giskd5KTADTDLr2STqUCuWpaV7U+JQa/1eWqNX2oJ+Aw==
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": "AGPL-3.0-or-later",
|
||||
@@ -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,20 @@
|
||||
# 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.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.
|
||||
@@ -8,7 +8,7 @@ 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
|
||||
- **Platform Targeting**: Advisories can be NanoClaw-specific or cross-platform
|
||||
- **Exploitability Context**: Advisories include exploitability score and rationale for triage
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -57,18 +57,30 @@ in each tool file).
|
||||
|
||||
Add the host-side IPC handlers for ClawSec operations.
|
||||
|
||||
**File**: `host/ipc-handler.ts`
|
||||
**File**: `src/ipc.ts`
|
||||
|
||||
```typescript
|
||||
// Add this import at the top
|
||||
import { registerClawSecHandlers } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
// 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';
|
||||
|
||||
// In your IPC handler setup function
|
||||
export function setupIpcHandlers() {
|
||||
// ... your existing handlers ...
|
||||
// Initialize these once in host startup and pass through deps
|
||||
const advisoryCacheManager = new AdvisoryCacheManager('/workspace/project/data', logger);
|
||||
const signatureVerifier = new SkillSignatureVerifier();
|
||||
|
||||
// Register ClawSec handlers
|
||||
registerClawSecHandlers();
|
||||
// In processTaskIpc switch:
|
||||
case 'refresh_advisory_cache':
|
||||
case 'verify_skill_signature':
|
||||
await handleAdvisoryIpc(
|
||||
data,
|
||||
{ advisoryCacheManager, signatureVerifier },
|
||||
logger,
|
||||
sourceGroup
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// existing task handling
|
||||
}
|
||||
```
|
||||
|
||||
@@ -76,23 +88,25 @@ export function setupIpcHandlers() {
|
||||
|
||||
Add the advisory cache manager to your host services.
|
||||
|
||||
**File**: `host/index.ts` (or your main entry point)
|
||||
**File**: `src/index.ts` (or your main entry point)
|
||||
|
||||
```typescript
|
||||
// Add this import
|
||||
import { startAdvisoryCache } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
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 ...
|
||||
|
||||
// Start ClawSec advisory cache (fetches feed every 6 hours)
|
||||
startAdvisoryCache({
|
||||
cacheFile: '/workspace/project/data/clawsec-advisory-cache.json',
|
||||
feedUrl: 'https://clawsec.prompt.security/advisories/feed.json',
|
||||
publicKeyPath: '/workspace/project/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem',
|
||||
refreshInterval: 6 * 60 * 60 * 1000, // 6 hours
|
||||
});
|
||||
// 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 ...
|
||||
}
|
||||
@@ -151,9 +165,9 @@ cat /workspace/project/data/clawsec-advisory-cache.json
|
||||
|
||||
You should see:
|
||||
- `feed`: Array of advisories
|
||||
- `signature`: Ed25519 signature
|
||||
- `lastFetch`: Timestamp of last update
|
||||
- `fetchedAt`: Timestamp of last update
|
||||
- `verified`: Should be `true`
|
||||
- `publicKeyFingerprint`: SHA-256 fingerprint of the pinned signing key
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -183,13 +197,13 @@ You can also call the MCP tools directly from agent code:
|
||||
```typescript
|
||||
// Check all installed skills
|
||||
const result = await tools.clawsec_check_advisories({
|
||||
skillsRoot: '/workspace/project/skills'
|
||||
installRoot: '/home/node/.claude/skills'
|
||||
});
|
||||
|
||||
// Check specific skill before installation
|
||||
const safetyCheck = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'risky-skill',
|
||||
version: '1.0.0'
|
||||
skillVersion: '1.0.0'
|
||||
});
|
||||
```
|
||||
|
||||
@@ -199,19 +213,19 @@ const safetyCheck = await tools.clawsec_check_skill_safety({
|
||||
|
||||
Default: `/workspace/project/data/clawsec-advisory-cache.json`
|
||||
|
||||
To change, update the `cacheFile` parameter in `startAdvisoryCache()`.
|
||||
To change, pass a different data directory path to `new AdvisoryCacheManager(dataDir, logger)`.
|
||||
|
||||
### Refresh Interval
|
||||
|
||||
Default: 6 hours
|
||||
|
||||
To change, update the `refreshInterval` parameter (in milliseconds).
|
||||
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 the `feedUrl` parameter.
|
||||
To use a mirror or custom feed, update `FEED_URL` in `skills/clawsec-nanoclaw/host-services/advisory-cache.ts`.
|
||||
|
||||
## Platform-Specific Advisories
|
||||
|
||||
@@ -222,7 +236,7 @@ ClawSec advisories can target specific platforms:
|
||||
- **`platforms: ["openclaw", "nanoclaw"]`**: Affects both
|
||||
- **No `platforms` field**: Applies to all platforms
|
||||
|
||||
The MCP tools automatically filter advisories based on your platform.
|
||||
Platform metadata is preserved in advisory records and can be filtered by your policy layer.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -260,7 +274,7 @@ Never manually edit the cache file - it will break signature verification.
|
||||
**Problem**: Advisory cache is empty or stale
|
||||
|
||||
**Solution**:
|
||||
1. Check that `startAdvisoryCache()` is called in your host entry point
|
||||
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`
|
||||
@@ -280,7 +294,7 @@ Never manually edit the cache file - it will break signature verification.
|
||||
**Problem**: Tools return errors about IPC
|
||||
|
||||
**Solution**:
|
||||
1. Verify IPC handlers are registered in `host/ipc-handler.ts`
|
||||
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
|
||||
@@ -290,8 +304,8 @@ Never manually edit the cache file - it will break signature verification.
|
||||
To remove ClawSec from NanoClaw:
|
||||
|
||||
1. Remove MCP tool registration from `ipc-mcp-stdio.ts`
|
||||
2. Remove IPC handler registration from `host/ipc-handler.ts`
|
||||
3. Remove `startAdvisoryCache()` call from host entry point
|
||||
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
|
||||
|
||||
@@ -56,9 +56,9 @@ ClawSec provides a complete security skill for NanoClaw deployments:
|
||||
- `clawsec_integrity_status` - View file baseline status
|
||||
- `clawsec_verify_audit` - Verify audit log hash chain
|
||||
|
||||
- **Advisory Cache Service**: Automatic feed fetching every 6 hours
|
||||
- **Advisory Cache Service**: Host-managed feed fetching with signature validation
|
||||
- **Signature Verification**: Ed25519-signed feeds ensure integrity
|
||||
- **Platform Filtering**: Shows only relevant advisories for NanoClaw
|
||||
- **Exploitability Context**: Surfaces `exploitability_score` and rationale to reduce alert fatigue
|
||||
- **IPC Communication**: Container-safe host communication
|
||||
|
||||
### Installation
|
||||
@@ -77,19 +77,19 @@ The skill integrates into three places:
|
||||
**1. MCP Tools** (container):
|
||||
```typescript
|
||||
// container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
import { clawsecTools } from '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js';
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js';
|
||||
```
|
||||
|
||||
**2. IPC Handlers** (host):
|
||||
```typescript
|
||||
// host/ipc-handler.ts
|
||||
import { registerClawSecHandlers } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
// src/ipc.ts
|
||||
import { handleAdvisoryIpc } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
```
|
||||
|
||||
**3. Cache Service** (host):
|
||||
```typescript
|
||||
// host/index.ts
|
||||
import { startAdvisoryCache } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
// src/index.ts
|
||||
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
```
|
||||
|
||||
### Advisory Feed
|
||||
@@ -142,10 +142,10 @@ Planned features for future releases:
|
||||
- [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](../../docs/SECURITY-SIGNING.md) - Signature verification details
|
||||
- [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: (link TBD)
|
||||
- NanoClaw Repository: https://github.com/qwibitai/nanoclaw
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-nanoclaw
|
||||
version: 0.0.1
|
||||
version: 0.0.2
|
||||
description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot
|
||||
---
|
||||
|
||||
@@ -10,7 +10,7 @@ Security advisory monitoring that protects your WhatsApp bot from known vulnerab
|
||||
|
||||
## Overview
|
||||
|
||||
ClawSec provides MCP tools that check installed skills against a curated feed of security advisories. It prevents installation of vulnerable skills and alerts you to issues in existing ones.
|
||||
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.
|
||||
|
||||
@@ -36,7 +36,7 @@ Do NOT use for:
|
||||
// Before installing any skill
|
||||
const safety = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'new-skill',
|
||||
version: '1.0.0' // optional
|
||||
skillVersion: '1.0.0' // optional
|
||||
});
|
||||
|
||||
if (!safety.safe) {
|
||||
@@ -48,14 +48,16 @@ if (!safety.safe) {
|
||||
### Security Audit
|
||||
|
||||
```typescript
|
||||
// Check all installed skills
|
||||
// Check all installed skills (defaults to ~/.claude/skills in the container)
|
||||
const result = await tools.clawsec_check_advisories({
|
||||
skillsRoot: '/workspace/project/skills' // optional
|
||||
installRoot: '/home/node/.claude/skills' // optional
|
||||
});
|
||||
|
||||
if (result.criticalCount > 0) {
|
||||
if (result.matches.some((m) =>
|
||||
m.advisory.severity === 'critical' || m.advisory.exploitability_score === 'high'
|
||||
)) {
|
||||
// Alert user immediately
|
||||
console.error('CRITICAL vulnerabilities found!');
|
||||
console.error('Urgent advisories found!');
|
||||
}
|
||||
```
|
||||
|
||||
@@ -64,8 +66,8 @@ if (result.criticalCount > 0) {
|
||||
```typescript
|
||||
// List advisories with filters
|
||||
const advisories = await tools.clawsec_list_advisories({
|
||||
platform: 'nanoclaw', // optional: nanoclaw, openclaw, or both
|
||||
severity: 'critical' // optional: critical, high, medium, low
|
||||
severity: 'high', // optional
|
||||
exploitabilityScore: 'high' // optional
|
||||
});
|
||||
```
|
||||
|
||||
@@ -75,7 +77,7 @@ const advisories = await tools.clawsec_list_advisories({
|
||||
|------|------|---------------|
|
||||
| Pre-install check | `clawsec_check_skill_safety` | `skillName` |
|
||||
| Audit all skills | `clawsec_check_advisories` | `installRoot` (optional) |
|
||||
| Browse feed | `clawsec_list_advisories` | `severity`, `type` (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) |
|
||||
@@ -110,7 +112,7 @@ if (safety.safe) {
|
||||
```typescript
|
||||
// Add to scheduled tasks
|
||||
schedule_task({
|
||||
prompt: "Check for security advisories using clawsec_check_advisories and alert if any critical issues found",
|
||||
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
|
||||
});
|
||||
@@ -125,8 +127,8 @@ You: I'll check installed skills for known vulnerabilities.
|
||||
[Use clawsec_check_advisories]
|
||||
|
||||
Response:
|
||||
✅ No critical issues found.
|
||||
- 2 low-severity advisories (not urgent)
|
||||
✅ No urgent issues found.
|
||||
- 2 low-severity/low-exploitability advisories
|
||||
- All skills up to date
|
||||
```
|
||||
|
||||
@@ -146,30 +148,33 @@ const safety = await tools.clawsec_check_skill_safety({
|
||||
if (safety.safe) await installSkill('untrusted-skill');
|
||||
```
|
||||
|
||||
### ❌ Ignoring platform filters
|
||||
### ❌ Ignoring exploitability context
|
||||
```typescript
|
||||
// DON'T: Check OpenClaw advisories on NanoClaw
|
||||
const advisories = await tools.clawsec_list_advisories({
|
||||
platform: 'openclaw' // Wrong platform!
|
||||
});
|
||||
// DON'T: Use severity only
|
||||
if (advisory.severity === 'high') {
|
||||
notifyNow(advisory);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO: Use correct platform or let it auto-filter
|
||||
const advisories = await tools.clawsec_list_advisories({
|
||||
platform: 'nanoclaw' // Correct
|
||||
});
|
||||
// DO: Use exploitability + severity
|
||||
if (
|
||||
advisory.exploitability_score === 'high' ||
|
||||
advisory.severity === 'critical'
|
||||
) {
|
||||
notifyNow(advisory);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Skipping critical severity
|
||||
```typescript
|
||||
// DON'T: Only check low severity
|
||||
if (result.lowCount > 0) alert();
|
||||
// DON'T: Ignore high exploitability in medium severity advisories
|
||||
if (advisory.severity === 'critical') alert();
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO: Prioritize critical and high
|
||||
if (result.criticalCount > 0 || result.highCount > 0) {
|
||||
// DO: Prioritize exploitability and severity together
|
||||
if (advisory.exploitability_score === 'high' || advisory.severity === 'critical') {
|
||||
// Alert immediately
|
||||
}
|
||||
```
|
||||
@@ -182,7 +187,7 @@ if (result.criticalCount > 0 || result.highCount > 0) {
|
||||
|
||||
**Signature Verification**: Ed25519 signed feeds
|
||||
|
||||
**Cache Location**: `/workspace/project/data/clawsec-cache.json`
|
||||
**Cache Location**: `/workspace/project/data/clawsec-advisory-cache.json`
|
||||
|
||||
See [INSTALL.md](./INSTALL.md) for setup and [docs/](./docs/) for advanced usage.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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-----
|
||||
@@ -35,6 +36,8 @@ export interface Advisory {
|
||||
action?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown' | string;
|
||||
exploitability_rationale?: string;
|
||||
affected: string[];
|
||||
}
|
||||
|
||||
@@ -376,42 +379,5 @@ export function evaluateSkillSafety(advisories: Advisory[]): {
|
||||
recommendation: 'install' | 'block' | 'review';
|
||||
reason: string;
|
||||
} {
|
||||
if (advisories.length === 0) {
|
||||
return { safe: true, recommendation: 'install', reason: 'No advisories found' };
|
||||
}
|
||||
|
||||
const hasMalicious = advisories.some((a) => a.type === 'malicious');
|
||||
const hasRemoveAction = advisories.some((a) => a.action === 'remove');
|
||||
const hasCritical = advisories.some((a) => a.severity === 'critical');
|
||||
const hasHigh = advisories.some((a) => a.severity === 'high');
|
||||
|
||||
if (hasMalicious || hasRemoveAction) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Malicious skill or removal recommended',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCritical) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Critical security advisory',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHigh) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'High severity advisory - user review recommended',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'Advisory found - review before installing',
|
||||
};
|
||||
return evaluateAdvisoryRisk(advisories);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,34 @@ export function versionMatches(version: string, versionSpec: string): boolean {
|
||||
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.
|
||||
*/
|
||||
@@ -269,10 +297,12 @@ export async function loadFeed(
|
||||
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 === 'malicious_skill' || type === 'malicious_plugin') return true;
|
||||
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;
|
||||
|
||||
@@ -294,15 +324,7 @@ export function findAdvisoryMatches(
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
for (const specifier of affected) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) continue;
|
||||
|
||||
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If version specified, check if it matches
|
||||
if (version && !versionMatches(version, parsed.versionSpec)) {
|
||||
if (!matchesAffectedSpecifier(specifier, skillName, version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export interface Advisory {
|
||||
references: string[];
|
||||
cvss_score?: number;
|
||||
nvd_url?: string;
|
||||
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown';
|
||||
exploitability_rationale?: string;
|
||||
source?: string;
|
||||
github_issue_url?: string;
|
||||
reporter?: {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
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.
|
||||
@@ -18,8 +20,10 @@ 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';
|
||||
|
||||
// Add these helper functions to the file:
|
||||
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
|
||||
@@ -84,10 +88,7 @@ function findAdvisoryMatches(
|
||||
const matchedAffected: string[] = [];
|
||||
|
||||
for (const affected of advisory.affected || []) {
|
||||
const atIndex = affected.lastIndexOf('@');
|
||||
const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected;
|
||||
|
||||
if (affectedName === skill.name || affectedName === skill.dirName) {
|
||||
if (matchesAffectedSpecifier(affected, skill.name, skill.version, skill.dirName)) {
|
||||
matchedAffected.push(affected);
|
||||
}
|
||||
}
|
||||
@@ -123,10 +124,8 @@ server.tool(
|
||||
}
|
||||
|
||||
// Read cache from shared mount
|
||||
const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json';
|
||||
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
const cacheData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
const installRoot = args.installRoot || path.join(process.env.HOME || '~', '.claude', 'skills');
|
||||
|
||||
// Discover installed skills
|
||||
@@ -153,6 +152,8 @@ server.tool(
|
||||
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,
|
||||
@@ -187,17 +188,13 @@ server.tool(
|
||||
skillVersion: z.string().optional().describe('Version of skill (optional, for version-specific checks)'),
|
||||
},
|
||||
async (args) => {
|
||||
const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json';
|
||||
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
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) => {
|
||||
const atIndex = affected.lastIndexOf('@');
|
||||
const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected;
|
||||
return affectedName === args.skillName;
|
||||
return matchesAffectedSpecifier(affected, args.skillName, args.skillVersion || null);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -215,34 +212,13 @@ server.tool(
|
||||
};
|
||||
}
|
||||
|
||||
// Evaluate severity
|
||||
const hasMalicious = matchingAdvisories.some((a: any) => a.type === 'malicious');
|
||||
const hasRemoveAction = matchingAdvisories.some((a: any) => a.action === 'remove');
|
||||
const hasCritical = matchingAdvisories.some((a: any) => a.severity === 'critical');
|
||||
const hasHigh = matchingAdvisories.some((a: any) => a.severity === 'high');
|
||||
|
||||
let recommendation: 'install' | 'block' | 'review';
|
||||
let reason: string;
|
||||
|
||||
if (hasMalicious || hasRemoveAction) {
|
||||
recommendation = 'block';
|
||||
reason = 'Malicious skill or removal recommended by ClawSec';
|
||||
} else if (hasCritical) {
|
||||
recommendation = 'block';
|
||||
reason = 'Critical security advisory - do not install';
|
||||
} else if (hasHigh) {
|
||||
recommendation = 'review';
|
||||
reason = 'High severity advisory - user review strongly recommended';
|
||||
} else {
|
||||
recommendation = 'review';
|
||||
reason = 'Advisory found - review details before installing';
|
||||
}
|
||||
const risk = evaluateAdvisoryRisk(matchingAdvisories);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
safe: false, // Always false when advisories exist
|
||||
safe: risk.safe,
|
||||
advisories: matchingAdvisories.map((a: any) => ({
|
||||
id: a.id,
|
||||
severity: a.severity,
|
||||
@@ -252,10 +228,13 @@ server.tool(
|
||||
action: a.action,
|
||||
published: a.published,
|
||||
affected: a.affected,
|
||||
exploitability_score: normalizeExploitabilityScore(a.exploitability_score),
|
||||
exploitability_rationale: a.exploitability_rationale || null,
|
||||
})),
|
||||
recommendation,
|
||||
reason,
|
||||
recommendation: risk.recommendation,
|
||||
reason: risk.reason,
|
||||
skillName: args.skillName,
|
||||
skillVersion: args.skillVersion || null,
|
||||
advisoryCount: matchingAdvisories.length,
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -280,18 +259,18 @@ server.tool(
|
||||
|
||||
server.tool(
|
||||
'clawsec_list_advisories',
|
||||
'List ClawSec advisories with optional filtering. Use this to browse security advisories, filter by severity/type, or search for specific affected skills.',
|
||||
'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.enum(['vulnerability', 'malicious', 'deprecated']).optional().describe('Filter by advisory type'),
|
||||
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) => {
|
||||
const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json';
|
||||
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
const cacheData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
let advisories = [...cacheData.feed.advisories];
|
||||
|
||||
// Apply filters
|
||||
@@ -299,7 +278,13 @@ server.tool(
|
||||
advisories = advisories.filter((a: any) => a.severity === args.severity);
|
||||
}
|
||||
if (args.type) {
|
||||
advisories = advisories.filter((a: any) => a.type === 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) =>
|
||||
@@ -307,9 +292,13 @@ server.tool(
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by severity (critical first) and published date (newest first)
|
||||
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
// 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 || '');
|
||||
@@ -336,6 +325,8 @@ server.tool(
|
||||
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,
|
||||
@@ -343,6 +334,7 @@ server.tool(
|
||||
filters: {
|
||||
severity: args.severity || null,
|
||||
type: args.type || null,
|
||||
exploitabilityScore: args.exploitabilityScore || null,
|
||||
affectedSkill: args.affectedSkill || null,
|
||||
limit: args.limit || null,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-nanoclaw",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"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",
|
||||
@@ -27,6 +27,11 @@
|
||||
"required": true,
|
||||
"description": "NanoClaw skill documentation"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and release notes"
|
||||
},
|
||||
{
|
||||
"path": "INSTALL.md",
|
||||
"required": true,
|
||||
@@ -62,6 +67,11 @@
|
||||
"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,
|
||||
@@ -112,9 +122,10 @@
|
||||
"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-specific advisory filtering (nanoclaw vs openclaw)",
|
||||
"Platform metadata preserved in advisory records for downstream filtering",
|
||||
"Containerized agent support with IPC communication"
|
||||
],
|
||||
"nanoclaw": {
|
||||
@@ -135,7 +146,7 @@
|
||||
},
|
||||
"integration": {
|
||||
"mcp_tools_file": "container/agent-runner/src/ipc-mcp-stdio.ts",
|
||||
"ipc_handlers_file": "host/ipc-handler.ts",
|
||||
"ipc_handlers_file": "src/ipc.ts",
|
||||
"cache_location": "/workspace/project/data/clawsec-advisory-cache.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,21 @@ All notable changes to the ClawSec Suite 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.1.4] - 2026-02-28
|
||||
|
||||
### Added
|
||||
|
||||
- Advisory output snippets now include exploitability context in suite quick-check and heartbeat examples.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clarified exploitability guidance to match runtime score values (`high|medium|low|unknown`).
|
||||
- Prioritization guidance now emphasizes high-exploitability advisories for immediate handling.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Kept exploitability enrichment in advisory workflows non-fatal per item so a single analysis failure does not abort feed updates.
|
||||
|
||||
## [0.1.3]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -121,6 +121,7 @@ else
|
||||
while IFS= read -r id; do
|
||||
[ -z "$id" ] && continue
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$FEED_TMP"
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | " Exploitability: \(.exploitability_score // "unknown" | ascii_upcase)"' "$FEED_TMP"
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | " Action: \(.action // "Review advisory details")"' "$FEED_TMP"
|
||||
done < "$NEW_IDS_FILE"
|
||||
else
|
||||
@@ -194,8 +195,18 @@ fi
|
||||
Heartbeat output should include:
|
||||
- suite version status,
|
||||
- advisory feed status,
|
||||
- new advisory list (if any),
|
||||
- new advisory list (if any) with exploitability scores,
|
||||
- installed skills that appear in advisory `affected` lists,
|
||||
- and a double-confirmation reminder before risky install/remove actions.
|
||||
|
||||
If your runtime sends alerts, treat `critical` and `high` advisories affecting installed skills as immediate notifications.
|
||||
### Exploitability-Based Prioritization
|
||||
|
||||
When alerting on advisories, prioritize by **exploitability score** in addition to severity:
|
||||
|
||||
- `high` exploitability: Trivially or easily exploitable with public tooling, immediate action required
|
||||
- `medium` exploitability: Exploitable with specific conditions, standard priority
|
||||
- `low` exploitability: Difficult to exploit or theoretical, low priority
|
||||
|
||||
**Priority Rule**: A HIGH severity + HIGH exploitability CVE should be treated more urgently than a CRITICAL severity + LOW exploitability CVE.
|
||||
|
||||
If your runtime sends alerts, treat `high` exploitability advisories affecting installed skills as immediate notifications, regardless of severity rating.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-suite
|
||||
version: 0.1.3
|
||||
version: 0.1.4
|
||||
description: ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
@@ -234,12 +234,25 @@ if [ -s "$NEW_IDS_FILE" ]; then
|
||||
while IFS= read -r id; do
|
||||
[ -z "$id" ] && continue
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$TMP/feed.json"
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | " Exploitability: \(.exploitability_score // "unknown" | ascii_upcase)"' "$TMP/feed.json"
|
||||
done < "$NEW_IDS_FILE"
|
||||
else
|
||||
echo "FEED_OK - no new advisories"
|
||||
fi
|
||||
```
|
||||
|
||||
## Exploitability Context
|
||||
|
||||
Advisories in the feed can include `exploitability_score` and `exploitability_rationale` fields to help agents prioritize real-world threats:
|
||||
|
||||
- **Exploitability scores**: `high`, `medium`, `low`, or `unknown`
|
||||
- **Context-aware assessment**: Considers attack vector, authentication requirements, and AI agent deployment patterns
|
||||
- **Exploit availability**: Detects public exploits and weaponization status
|
||||
|
||||
When processing advisories, prioritize by exploitability in addition to severity. A HIGH severity + HIGH exploitability CVE is more urgent than a CRITICAL severity + LOW exploitability CVE.
|
||||
|
||||
For detailed methodology, see the [exploitability scoring documentation](../../wiki/exploitability-scoring.md).
|
||||
|
||||
## Heartbeat Integration
|
||||
|
||||
Use the suite heartbeat script as the single periodic security check entrypoint:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-suite",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -11,25 +11,12 @@
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pass, fail, report, exitWithResults } from "./lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
|
||||
const { advisoryAppliesToOpenclaw } = await import(`${LIB_PATH}/advisory_scope.mjs`);
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount += 1;
|
||||
console.log(`\u2713 ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount += 1;
|
||||
console.error(`\u2717 ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function testFindMatchesFiltersByApplicationScope() {
|
||||
const testName = "advisoryAppliesToOpenclaw: openclaw + legacy advisories are considered";
|
||||
|
||||
@@ -89,10 +76,8 @@ function runTests() {
|
||||
testFindMatchesAcceptsApplicationArray();
|
||||
testInvalidApplicationValueFallsBackCompat();
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runTests();
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pass, fail, report, exitWithResults, createTempDir } from "./lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
|
||||
@@ -27,29 +27,6 @@ const { isAdvisorySuppressed, loadAdvisorySuppression } = await import(
|
||||
);
|
||||
|
||||
let tempDir;
|
||||
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)}`);
|
||||
}
|
||||
|
||||
async function setupTestDir() {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "advisory-suppression-test-"));
|
||||
}
|
||||
|
||||
async function cleanupTestDir() {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function makeMatch(advisoryId, skillName, version = "1.0.0") {
|
||||
return {
|
||||
@@ -190,7 +167,7 @@ async function testMissingAdvisoryId() {
|
||||
async function testLoadWithAdvisorySentinel() {
|
||||
const testName = "loadAdvisorySuppression: loads config with advisory sentinel";
|
||||
try {
|
||||
const configFile = path.join(tempDir, "advisory-config.json");
|
||||
const configFile = path.join(tempDir.path, "advisory-config.json");
|
||||
await fs.writeFile(configFile, JSON.stringify({
|
||||
enabledFor: ["advisory"],
|
||||
suppressions: [{
|
||||
@@ -215,7 +192,7 @@ async function testLoadWithAdvisorySentinel() {
|
||||
async function testLoadWithMissingSentinel() {
|
||||
const testName = "loadAdvisorySuppression: missing sentinel returns empty config";
|
||||
try {
|
||||
const configFile = path.join(tempDir, "no-sentinel.json");
|
||||
const configFile = path.join(tempDir.path, "no-sentinel.json");
|
||||
await fs.writeFile(configFile, JSON.stringify({
|
||||
suppressions: [{
|
||||
checkId: "CVE-2026-25593",
|
||||
@@ -239,7 +216,7 @@ async function testLoadWithMissingSentinel() {
|
||||
async function testLoadWithAuditOnlySentinel() {
|
||||
const testName = "loadAdvisorySuppression: audit-only sentinel returns empty for advisory";
|
||||
try {
|
||||
const configFile = path.join(tempDir, "audit-only.json");
|
||||
const configFile = path.join(tempDir.path, "audit-only.json");
|
||||
await fs.writeFile(configFile, JSON.stringify({
|
||||
enabledFor: ["audit"],
|
||||
suppressions: [{
|
||||
@@ -264,7 +241,7 @@ async function testLoadWithAuditOnlySentinel() {
|
||||
async function testLoadWithBothSentinels() {
|
||||
const testName = "loadAdvisorySuppression: both audit+advisory sentinels activates advisory";
|
||||
try {
|
||||
const configFile = path.join(tempDir, "both-sentinel.json");
|
||||
const configFile = path.join(tempDir.path, "both-sentinel.json");
|
||||
await fs.writeFile(configFile, JSON.stringify({
|
||||
enabledFor: ["audit", "advisory"],
|
||||
suppressions: [{
|
||||
@@ -289,7 +266,7 @@ async function testLoadWithBothSentinels() {
|
||||
async function testLoadNonexistentExplicitPath() {
|
||||
const testName = "loadAdvisorySuppression: explicit nonexistent path throws";
|
||||
try {
|
||||
await loadAdvisorySuppression(path.join(tempDir, "does-not-exist.json"));
|
||||
await loadAdvisorySuppression(path.join(tempDir.path, "does-not-exist.json"));
|
||||
fail(testName, "Expected error for nonexistent explicit path");
|
||||
} catch (error) {
|
||||
if (String(error).includes("not found")) {
|
||||
@@ -328,7 +305,7 @@ async function testLoadNoConfigReturnsEmpty() {
|
||||
async function testEnvPathHomeExpansion() {
|
||||
const testName = "loadAdvisorySuppression: OPENCLAW_AUDIT_CONFIG expands $HOME";
|
||||
try {
|
||||
const configFile = path.join(tempDir, "env-home.json");
|
||||
const configFile = path.join(tempDir.path, "env-home.json");
|
||||
await fs.writeFile(configFile, JSON.stringify({
|
||||
enabledFor: ["advisory"],
|
||||
suppressions: [{
|
||||
@@ -341,7 +318,7 @@ async function testEnvPathHomeExpansion() {
|
||||
|
||||
const savedConfig = process.env.OPENCLAW_AUDIT_CONFIG;
|
||||
const savedHome = process.env.HOME;
|
||||
process.env.HOME = tempDir;
|
||||
process.env.HOME = tempDir.path;
|
||||
process.env.OPENCLAW_AUDIT_CONFIG = "$HOME/env-home.json";
|
||||
try {
|
||||
const config = await loadAdvisorySuppression();
|
||||
@@ -390,7 +367,7 @@ async function testEscapedHomeTokenRejected() {
|
||||
async function runAllTests() {
|
||||
console.log("=== Advisory Suppression Tests ===\n");
|
||||
|
||||
await setupTestDir();
|
||||
tempDir = await createTempDir();
|
||||
|
||||
try {
|
||||
// isAdvisorySuppressed tests
|
||||
@@ -412,15 +389,11 @@ async function runAllTests() {
|
||||
await testEnvPathHomeExpansion();
|
||||
await testEscapedHomeTokenRejected();
|
||||
} finally {
|
||||
await cleanupTestDir();
|
||||
await tempDir.cleanup();
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
|
||||
@@ -14,9 +14,17 @@
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
pass,
|
||||
fail,
|
||||
report,
|
||||
exitWithResults,
|
||||
generateEd25519KeyPair,
|
||||
signPayload,
|
||||
createTempDir,
|
||||
} from "./lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
|
||||
@@ -26,33 +34,7 @@ const { verifySignedPayload, loadLocalFeed, isValidFeedPayload } = await import(
|
||||
`${LIB_PATH}/feed.mjs`
|
||||
);
|
||||
|
||||
let tempDir;
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function generateEd25519KeyPair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
return { publicKeyPem, privateKeyPem };
|
||||
}
|
||||
|
||||
function signPayload(data, privateKeyPem) {
|
||||
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
|
||||
return signature.toString("base64");
|
||||
}
|
||||
let tempDirCleanup;
|
||||
|
||||
function createValidFeed() {
|
||||
return JSON.stringify(
|
||||
@@ -88,16 +70,6 @@ function createChecksumManifest(files) {
|
||||
);
|
||||
}
|
||||
|
||||
async function setupTestDir() {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-test-"));
|
||||
}
|
||||
|
||||
async function cleanupTestDir() {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: verifySignedPayload - valid signature
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -252,11 +224,11 @@ async function testLoadLocalFeed_ValidSignedFeed() {
|
||||
const checksumSignature = signPayload(checksumManifest, privateKeyPem);
|
||||
|
||||
// Write files
|
||||
const feedPath = path.join(tempDir, "feed.json");
|
||||
const sigPath = path.join(tempDir, "feed.json.sig");
|
||||
const checksumPath = path.join(tempDir, "checksums.json");
|
||||
const checksumSigPath = path.join(tempDir, "checksums.json.sig");
|
||||
const keyPath = path.join(tempDir, "feed-signing-public.pem");
|
||||
const feedPath = path.join(globalThis.__testTempDir, "feed.json");
|
||||
const sigPath = path.join(globalThis.__testTempDir, "feed.json.sig");
|
||||
const checksumPath = path.join(globalThis.__testTempDir, "checksums.json");
|
||||
const checksumSigPath = path.join(globalThis.__testTempDir, "checksums.json.sig");
|
||||
const keyPath = path.join(globalThis.__testTempDir, "feed-signing-public.pem");
|
||||
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||
@@ -293,7 +265,7 @@ async function testLoadLocalFeed_AdvisoriesPrefixedChecksumKeys() {
|
||||
const feedContent = createValidFeed();
|
||||
const feedSignature = signPayload(feedContent, privateKeyPem);
|
||||
|
||||
const advisoriesDir = path.join(tempDir, "advisories");
|
||||
const advisoriesDir = path.join(globalThis.__testTempDir, "advisories");
|
||||
await fs.mkdir(advisoriesDir, { recursive: true });
|
||||
|
||||
const checksumManifest = createChecksumManifest({
|
||||
@@ -347,8 +319,8 @@ async function testLoadLocalFeed_TamperedFeedFails() {
|
||||
// Tamper with feed after signing
|
||||
const tamperedFeed = feedContent.replace("TEST-001", "TAMPERED-001");
|
||||
|
||||
const feedPath = path.join(tempDir, "tampered-feed.json");
|
||||
const sigPath = path.join(tempDir, "tampered-feed.json.sig");
|
||||
const feedPath = path.join(globalThis.__testTempDir, "tampered-feed.json");
|
||||
const sigPath = path.join(globalThis.__testTempDir, "tampered-feed.json.sig");
|
||||
|
||||
await fs.writeFile(feedPath, tamperedFeed);
|
||||
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||
@@ -383,8 +355,8 @@ async function testLoadLocalFeed_MissingSignatureFails() {
|
||||
const { publicKeyPem } = generateEd25519KeyPair();
|
||||
const feedContent = createValidFeed();
|
||||
|
||||
const feedPath = path.join(tempDir, "nosig-feed.json");
|
||||
const sigPath = path.join(tempDir, "nosig-feed.json.sig");
|
||||
const feedPath = path.join(globalThis.__testTempDir, "nosig-feed.json");
|
||||
const sigPath = path.join(globalThis.__testTempDir, "nosig-feed.json.sig");
|
||||
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
// Don't write signature file
|
||||
@@ -418,7 +390,7 @@ async function testLoadLocalFeed_AllowUnsignedBypasses() {
|
||||
try {
|
||||
const feedContent = createValidFeed();
|
||||
|
||||
const feedPath = path.join(tempDir, "unsigned-feed.json");
|
||||
const feedPath = path.join(globalThis.__testTempDir, "unsigned-feed.json");
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
|
||||
const feed = await loadLocalFeed(feedPath, {
|
||||
@@ -462,10 +434,10 @@ async function testLoadLocalFeed_ChecksumMismatchFails() {
|
||||
);
|
||||
const checksumSignature = signPayload(badChecksumManifest, privateKeyPem);
|
||||
|
||||
const feedPath = path.join(tempDir, "badcs-feed.json");
|
||||
const sigPath = path.join(tempDir, "badcs-feed.json.sig");
|
||||
const checksumPath = path.join(tempDir, "badcs-checksums.json");
|
||||
const checksumSigPath = path.join(tempDir, "badcs-checksums.json.sig");
|
||||
const feedPath = path.join(globalThis.__testTempDir, "badcs-feed.json");
|
||||
const sigPath = path.join(globalThis.__testTempDir, "badcs-feed.json.sig");
|
||||
const checksumPath = path.join(globalThis.__testTempDir, "badcs-checksums.json");
|
||||
const checksumSigPath = path.join(globalThis.__testTempDir, "badcs-checksums.json.sig");
|
||||
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||
@@ -580,7 +552,11 @@ async function testIsValidFeedPayload_AdvisoryMissingId() {
|
||||
async function runTests() {
|
||||
console.log("=== ClawSec Feed Verification Tests ===\n");
|
||||
|
||||
await setupTestDir();
|
||||
const tempDir = await createTempDir();
|
||||
tempDirCleanup = tempDir.cleanup;
|
||||
|
||||
// Store temp dir path in module scope for tests to access
|
||||
globalThis.__testTempDir = tempDir.path;
|
||||
|
||||
try {
|
||||
// Signature verification tests
|
||||
@@ -604,14 +580,11 @@ async function runTests() {
|
||||
await testIsValidFeedPayload_MissingVersion();
|
||||
await testIsValidFeedPayload_AdvisoryMissingId();
|
||||
} finally {
|
||||
await cleanupTestDir();
|
||||
await tempDirCleanup();
|
||||
}
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import fc from "fast-check";
|
||||
import { parseAffectedSpecifier } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
||||
import { normalizeSkillName, resolveConfiguredPath, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
|
||||
|
||||
const SAFE_SEGMENT = fc
|
||||
.array(fc.constantFrom(...("abcdefghijklmnopqrstuvwxyz0123456789-_")), { minLength: 1, maxLength: 24 })
|
||||
.map((chars) => chars.join(""));
|
||||
|
||||
/**
|
||||
* Runs property-based fuzz checks for advisory parsing and utility behavior.
|
||||
*/
|
||||
export function runFuzzProperties() {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (raw) => {
|
||||
const expected = String(raw ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
assert.equal(normalizeSkillName(raw), expected);
|
||||
}),
|
||||
{ numRuns: 300 },
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.array(fc.string(), { maxLength: 40 }), (values) => {
|
||||
const deduped = uniqueStrings(values);
|
||||
assert.deepEqual(deduped, Array.from(new Set(values)));
|
||||
}),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.string(), fc.string(), (left, right) => {
|
||||
const rawSpecifier = `${left}@${right}`;
|
||||
const specifier = rawSpecifier.trim();
|
||||
const parsed = parseAffectedSpecifier(rawSpecifier);
|
||||
assert.ok(parsed !== null);
|
||||
|
||||
const atIndex = specifier.lastIndexOf("@");
|
||||
if (atIndex <= 0) {
|
||||
assert.equal(parsed.name, specifier);
|
||||
assert.equal(parsed.versionSpec, "*");
|
||||
} else {
|
||||
assert.equal(parsed.name, specifier.slice(0, atIndex));
|
||||
assert.equal(parsed.versionSpec, specifier.slice(atIndex + 1));
|
||||
}
|
||||
}),
|
||||
{ numRuns: 300 },
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(SAFE_SEGMENT, (suffix) => {
|
||||
const fallback = `/tmp/clawsec-suite/${suffix}`;
|
||||
const resolved = resolveConfiguredPath(`\\$HOME/${suffix}`, fallback, {
|
||||
label: "FUZZ_PATH",
|
||||
});
|
||||
assert.equal(resolved, path.normalize(fallback));
|
||||
}),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Property-based fuzzing checks for core advisory parsing/path helpers.
|
||||
*
|
||||
* Run: node skills/clawsec-suite/test/fuzz_properties.test.mjs
|
||||
*/
|
||||
|
||||
import { pass, fail, report, exitWithResults } from "./lib/test_harness.mjs";
|
||||
import { runFuzzProperties } from "./fuzz_properties.js";
|
||||
|
||||
console.log("=== ClawSec Fast-Check Fuzz Properties ===\n");
|
||||
|
||||
try {
|
||||
runFuzzProperties();
|
||||
pass("Property-based fuzz tests");
|
||||
} catch (error) {
|
||||
fail("Property-based fuzz tests", error);
|
||||
}
|
||||
|
||||
report();
|
||||
exitWithResults();
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Property-based fuzz tests for semver matching, advisory scope, and suppression matching.
|
||||
*
|
||||
* Run: node skills/clawsec-suite/test/fuzz_semver_scope_suppression.test.mjs
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import fc from "fast-check";
|
||||
import { advisoryAppliesToOpenclaw } from "../hooks/clawsec-advisory-guardian/lib/advisory_scope.mjs";
|
||||
import { isAdvisorySuppressed } from "../hooks/clawsec-advisory-guardian/lib/suppression.mjs";
|
||||
import { compareSemver, parseSemver, versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
|
||||
|
||||
const semverCoreArb = fc.tuple(
|
||||
fc.integer({ min: 0, max: 999 }),
|
||||
fc.integer({ min: 0, max: 999 }),
|
||||
fc.integer({ min: 0, max: 999 }),
|
||||
);
|
||||
|
||||
const semverArb = semverCoreArb.map(([major, minor, patch]) => `${major}.${minor}.${patch}`);
|
||||
const idArb = fc.string({ minLength: 1, maxLength: 24 });
|
||||
const skillArb = fc.string({ minLength: 1, maxLength: 24 });
|
||||
|
||||
function runSemverProperties() {
|
||||
fc.assert(
|
||||
fc.property(semverCoreArb, ([major, minor, patch]) => {
|
||||
const version = `v${major}.${minor}.${patch}`;
|
||||
assert.deepEqual(parseSemver(version), [major, minor, patch]);
|
||||
}),
|
||||
{ numRuns: 250 },
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(semverArb, semverArb, (left, right) => {
|
||||
const leftVsRight = compareSemver(left, right);
|
||||
const rightVsLeft = compareSemver(right, left);
|
||||
assert.notEqual(leftVsRight, null);
|
||||
assert.notEqual(rightVsLeft, null);
|
||||
assert.equal(leftVsRight, -rightVsLeft);
|
||||
}),
|
||||
{ numRuns: 250 },
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(semverArb, semverArb, (left, right) => {
|
||||
const compared = compareSemver(left, right);
|
||||
assert.notEqual(compared, null);
|
||||
|
||||
assert.equal(versionMatches(left, `>=${right}`), compared >= 0);
|
||||
assert.equal(versionMatches(left, `<=${right}`), compared <= 0);
|
||||
assert.equal(versionMatches(left, `>${right}`), compared > 0);
|
||||
assert.equal(versionMatches(left, `<${right}`), compared < 0);
|
||||
assert.equal(versionMatches(left, `=${right}`), compared === 0);
|
||||
}),
|
||||
{ numRuns: 250 },
|
||||
);
|
||||
}
|
||||
|
||||
function runAdvisoryScopeProperties() {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (application) => {
|
||||
const normalized = application.trim().toLowerCase();
|
||||
const expected = normalized === "" || normalized === "openclaw" || normalized === "all";
|
||||
assert.equal(advisoryAppliesToOpenclaw({ application }), expected);
|
||||
}),
|
||||
{ numRuns: 250 },
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.array(fc.string(), { maxLength: 8 }), (applications) => {
|
||||
const normalized = applications
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const expected =
|
||||
normalized.length === 0 || normalized.includes("openclaw") || normalized.includes("all");
|
||||
assert.equal(advisoryAppliesToOpenclaw({ application: applications }), expected);
|
||||
}),
|
||||
{ numRuns: 250 },
|
||||
);
|
||||
|
||||
assert.equal(advisoryAppliesToOpenclaw({}), true);
|
||||
assert.equal(advisoryAppliesToOpenclaw({ application: null }), true);
|
||||
}
|
||||
|
||||
function runSuppressionProperties() {
|
||||
fc.assert(
|
||||
fc.property(idArb, skillArb, (id, skill) => {
|
||||
const match = {
|
||||
advisory: { id },
|
||||
skill: { name: skill.toUpperCase() },
|
||||
};
|
||||
const suppressions = [
|
||||
{
|
||||
checkId: id,
|
||||
skill: skill.toLowerCase(),
|
||||
reason: "fuzz",
|
||||
suppressedAt: "2026-02-25",
|
||||
},
|
||||
];
|
||||
assert.equal(isAdvisorySuppressed(match, suppressions), true);
|
||||
}),
|
||||
{ numRuns: 250 },
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(idArb, idArb, skillArb, (targetId, otherId, skill) => {
|
||||
const differentId = targetId === otherId ? `${otherId}-x` : otherId;
|
||||
const match = {
|
||||
advisory: { id: targetId },
|
||||
skill: { name: skill },
|
||||
};
|
||||
const suppressions = [
|
||||
{
|
||||
checkId: differentId,
|
||||
skill,
|
||||
reason: "fuzz",
|
||||
suppressedAt: "2026-02-25",
|
||||
},
|
||||
];
|
||||
assert.equal(isAdvisorySuppressed(match, suppressions), false);
|
||||
}),
|
||||
{ numRuns: 250 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("=== ClawSec Semver/Scope/Suppression Fuzz Properties ===\n");
|
||||
runSemverProperties();
|
||||
runAdvisoryScopeProperties();
|
||||
runSuppressionProperties();
|
||||
console.log("=== Results: all fuzz properties passed ===");
|
||||
} catch (error) {
|
||||
console.error("Fuzz property test failed:");
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -18,37 +18,19 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
pass,
|
||||
fail,
|
||||
report,
|
||||
exitWithResults,
|
||||
generateEd25519KeyPair,
|
||||
signPayload,
|
||||
} from "./lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "guarded_skill_install.mjs");
|
||||
|
||||
let tempDir;
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function generateEd25519KeyPair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
return { publicKeyPem, privateKeyPem };
|
||||
}
|
||||
|
||||
function signPayload(data, privateKeyPem) {
|
||||
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
|
||||
return signature.toString("base64");
|
||||
}
|
||||
|
||||
function createFeed(advisories) {
|
||||
return JSON.stringify(
|
||||
@@ -416,11 +398,8 @@ async function runTests() {
|
||||
await cleanupTestDir();
|
||||
}
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Shared test harness for clawsec-suite tests.
|
||||
* Provides consistent test reporting and runner utilities.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
/**
|
||||
* Records a passing test.
|
||||
* @param {string} name - Test name
|
||||
*/
|
||||
export function pass(name) {
|
||||
passCount++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a failing test.
|
||||
* @param {string} name - Test name
|
||||
* @param {Error|string} error - Error details
|
||||
*/
|
||||
export function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current test statistics.
|
||||
* @returns {{passCount: number, failCount: number}}
|
||||
*/
|
||||
export function getStats() {
|
||||
return { passCount, failCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports final test results to console.
|
||||
*/
|
||||
export function report() {
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exits with appropriate code based on test results.
|
||||
* Exit code 0 for success, 1 for failures.
|
||||
*/
|
||||
export function exitWithResults() {
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated test runner with its own pass/fail counters.
|
||||
* Useful for running independent test suites within the same process.
|
||||
* @returns {{pass: Function, fail: Function, getStats: Function, report: Function, exitWithResults: Function}}
|
||||
*/
|
||||
export function createTestRunner() {
|
||||
let localPassCount = 0;
|
||||
let localFailCount = 0;
|
||||
|
||||
return {
|
||||
/**
|
||||
* Records a passing test.
|
||||
* @param {string} name - Test name
|
||||
*/
|
||||
pass(name) {
|
||||
localPassCount++;
|
||||
console.log(`✓ ${name}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Records a failing test.
|
||||
* @param {string} name - Test name
|
||||
* @param {Error|string} error - Error details
|
||||
*/
|
||||
fail(name, error) {
|
||||
localFailCount++;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets current test statistics.
|
||||
* @returns {{passCount: number, failCount: number}}
|
||||
*/
|
||||
getStats() {
|
||||
return { passCount: localPassCount, failCount: localFailCount };
|
||||
},
|
||||
|
||||
/**
|
||||
* Reports final test results to console.
|
||||
*/
|
||||
report() {
|
||||
console.log(`\n=== Results: ${localPassCount} passed, ${localFailCount} failed ===`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Exits with appropriate code based on test results.
|
||||
* Exit code 0 for success, 1 for failures.
|
||||
*/
|
||||
exitWithResults() {
|
||||
if (localFailCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an Ed25519 keypair for test use.
|
||||
* @returns {{publicKeyPem: string, privateKeyPem: string}}
|
||||
*/
|
||||
export function generateEd25519KeyPair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
return { publicKeyPem, privateKeyPem };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a payload with an Ed25519 private key.
|
||||
* @param {string} data - Data to sign
|
||||
* @param {string} privateKeyPem - PEM-encoded private key
|
||||
* @returns {string} Base64-encoded signature
|
||||
*/
|
||||
export function signPayload(data, privateKeyPem) {
|
||||
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
|
||||
return signature.toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary directory for test use.
|
||||
* @returns {Promise<{path: string, cleanup: Function}>} Object with temp dir path and cleanup function
|
||||
*/
|
||||
export async function createTempDir() {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-test-"));
|
||||
|
||||
return {
|
||||
path: tmpDir,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily sets an environment variable for the duration of a function.
|
||||
* Restores the original value (or deletes the variable) after the function completes.
|
||||
* @param {string} key - Environment variable name
|
||||
* @param {string|undefined} value - Value to set (undefined to delete)
|
||||
* @param {Function} fn - Function to execute with the modified environment
|
||||
* @returns {Promise<*>} Result of the function
|
||||
*/
|
||||
export async function withEnv(key, value, fn) {
|
||||
const oldValue = process.env[key];
|
||||
try {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
return await fn();
|
||||
} finally {
|
||||
if (oldValue === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = oldValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,43 +8,12 @@
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pass, fail, report, exitWithResults, withEnv } from "./lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
|
||||
const { resolveUserPath, resolveConfiguredPath } = await import(`${LIB_PATH}/utils.mjs`);
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount += 1;
|
||||
console.log(`\u2713 ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount += 1;
|
||||
console.error(`\u2717 ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
async function withEnv(key, value, fn) {
|
||||
const oldValue = process.env[key];
|
||||
try {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
return await fn();
|
||||
} finally {
|
||||
if (oldValue === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = oldValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testTildeExpansion() {
|
||||
const testName = "resolveUserPath: expands leading tilde";
|
||||
await withEnv("HOME", "/tmp/clawsec-home", async () => {
|
||||
@@ -157,10 +126,8 @@ async function runTests() {
|
||||
await testConfiguredPathFallbackOnInvalidExplicit();
|
||||
await testConfiguredPathUsesValidExplicit();
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
|
||||
@@ -15,24 +15,11 @@ import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pass, fail, report, exitWithResults } from "./lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "discover_skill_catalog.mjs");
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount += 1;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount += 1;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function runCatalogScript(args, env = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("node", [SCRIPT_PATH, ...args], {
|
||||
@@ -237,11 +224,8 @@ async function runTests() {
|
||||
await testInvalidRemotePayloadFallsBack();
|
||||
await testUnreachableRemoteFallsBack();
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
|
||||
@@ -16,39 +16,16 @@
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pass, fail, report, exitWithResults, createTempDir } from "../../clawsec-suite/test/lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "render_report.mjs");
|
||||
const NODE_BIN = process.execPath;
|
||||
|
||||
let tempDir;
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
async function setupTestDir() {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "render-report-test-"));
|
||||
}
|
||||
|
||||
async function cleanupTestDir() {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function createAuditJson(findings) {
|
||||
return JSON.stringify({
|
||||
@@ -730,7 +707,8 @@ async function testConfigWithoutEnableFlagDoesNotSuppress() {
|
||||
// Main test runner
|
||||
// -----------------------------------------------------------------------------
|
||||
async function runAllTests() {
|
||||
await setupTestDir();
|
||||
const tmpDir = await createTempDir();
|
||||
tempDir = tmpDir.path;
|
||||
|
||||
try {
|
||||
await testSuppressedFindingsDisplayed();
|
||||
@@ -745,16 +723,11 @@ async function runAllTests() {
|
||||
await testEmptySuppressions();
|
||||
await testConfigWithoutEnableFlagDoesNotSuppress();
|
||||
} finally {
|
||||
await cleanupTestDir();
|
||||
await tmpDir.cleanup();
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Passed: ${passCount}`);
|
||||
console.log(`Failed: ${failCount}`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
|
||||
@@ -19,57 +19,33 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import {
|
||||
pass,
|
||||
fail,
|
||||
report,
|
||||
exitWithResults,
|
||||
createTempDir,
|
||||
withEnv,
|
||||
} from "../../clawsec-suite/test/lib/test_harness.mjs";
|
||||
import { loadSuppressionConfig } from "../scripts/load_suppression_config.mjs";
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount += 1;
|
||||
console.log(`\u2713 ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount += 1;
|
||||
console.error(`\u2717 ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary file with the given content.
|
||||
* Wrapper around createTempDir for test config file creation.
|
||||
* @param {string} content - File content
|
||||
* @returns {Promise<{path: string, cleanup: Function}>}
|
||||
*/
|
||||
async function withTempFile(content) {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-"));
|
||||
const tmpFile = path.join(tmpDir, "test-config.json");
|
||||
const tmpDir = await createTempDir();
|
||||
const tmpFile = path.join(tmpDir.path, "test-config.json");
|
||||
await fs.writeFile(tmpFile, content, "utf8");
|
||||
|
||||
return {
|
||||
path: tmpFile,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
},
|
||||
cleanup: tmpDir.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
async function withEnv(key, value, fn) {
|
||||
const oldValue = process.env[key];
|
||||
try {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
return await fn();
|
||||
} finally {
|
||||
if (oldValue === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = oldValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Suppress stderr output during a function call (avoids noisy warnings in test output). */
|
||||
async function silenceStderr(fn) {
|
||||
const original = process.stderr.write;
|
||||
@@ -748,11 +724,8 @@ async function runTests() {
|
||||
await testMissingSentinel();
|
||||
await testWrongSentinel();
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Property-based fuzz tests for openclaw suppression config gating behavior.
|
||||
*
|
||||
* Run: node skills/openclaw-audit-watchdog/test/suppression_config_fuzz.test.mjs
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fc from "fast-check";
|
||||
import { loadSuppressionConfig } from "../scripts/load_suppression_config.mjs";
|
||||
|
||||
const pipelineArb = fc.constantFrom("audit", "advisory", "watchdog");
|
||||
|
||||
function makeValidConfig({ pipeline, includePipeline }) {
|
||||
const enabledFor = includePipeline ? [pipeline.toUpperCase(), "other"] : ["other"];
|
||||
return JSON.stringify({
|
||||
enabledFor,
|
||||
suppressions: [
|
||||
{
|
||||
checkId: "SCAN-001",
|
||||
skill: "soul-guardian",
|
||||
reason: "fuzz test",
|
||||
suppressedAt: "2026-02-25",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function withTempConfig(content, fn) {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "watchdog-fuzz-"));
|
||||
const configPath = path.join(tmpDir, "suppression.json");
|
||||
await fs.writeFile(configPath, content, "utf8");
|
||||
try {
|
||||
await fn(configPath);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function withSilencedStderr(fn) {
|
||||
const originalWrite = process.stderr.write;
|
||||
process.stderr.write = () => true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
}
|
||||
|
||||
async function runProperties() {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(fc.string(), pipelineArb, async (rawPath, pipeline) => {
|
||||
const result = await loadSuppressionConfig(rawPath, { enabled: false, pipeline });
|
||||
assert.equal(result.source, "none");
|
||||
assert.deepEqual(result.suppressions, []);
|
||||
}),
|
||||
{ numRuns: 120 },
|
||||
);
|
||||
|
||||
await fc.assert(
|
||||
fc.asyncProperty(pipelineArb, fc.boolean(), async (pipeline, includePipeline) => {
|
||||
const content = makeValidConfig({ pipeline, includePipeline });
|
||||
await withTempConfig(content, async (configPath) => {
|
||||
const result = await withSilencedStderr(() =>
|
||||
loadSuppressionConfig(configPath, { enabled: true, pipeline }),
|
||||
);
|
||||
if (includePipeline) {
|
||||
assert.equal(result.source, configPath);
|
||||
assert.equal(result.suppressions.length, 1);
|
||||
assert.equal(result.suppressions[0].checkId, "SCAN-001");
|
||||
} else {
|
||||
assert.equal(result.source, "none");
|
||||
assert.deepEqual(result.suppressions, []);
|
||||
}
|
||||
});
|
||||
}),
|
||||
{ numRuns: 80 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("=== OpenClaw Suppression Config Fuzz Properties ===\n");
|
||||
await runProperties();
|
||||
console.log("=== Results: all fuzz properties passed ===");
|
||||
} catch (error) {
|
||||
console.error("Fuzz property test failed:");
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export interface Advisory {
|
||||
references?: string[];
|
||||
cvss_score?: number | null;
|
||||
nvd_url?: string;
|
||||
platforms?: string[];
|
||||
// Community report fields (source defaults to "Prompt Security Staff" when absent)
|
||||
source?: string;
|
||||
github_issue_url?: string;
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Exploitability Analyzer - Analyzes CVE exploitability in OpenClaw/NanoClaw deployments
|
||||
|
||||
Usage:
|
||||
python utils/analyze_exploitability.py --help
|
||||
echo '{"cve_id":"CVE-2026-27488","cvss_score":7.3}' | python utils/analyze_exploitability.py --json
|
||||
python utils/analyze_exploitability.py --test-cases
|
||||
|
||||
Example:
|
||||
cat cve-data.json | python utils/analyze_exploitability.py --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_cvss_vector(vector_string: str) -> dict[str, str]:
|
||||
"""
|
||||
Parse CVSS v2, v3.0, or v3.1 vector string into components.
|
||||
|
||||
Args:
|
||||
vector_string: CVSS vector (e.g., "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H")
|
||||
|
||||
Returns:
|
||||
Dictionary of CVSS metrics and values
|
||||
"""
|
||||
if not vector_string:
|
||||
return {}
|
||||
|
||||
metrics = {}
|
||||
normalized = vector_string.strip()
|
||||
|
||||
# Remove leading CVSS v3.x prefix if present (e.g., "CVSS:3.1/")
|
||||
if normalized.startswith("CVSS:3"):
|
||||
_, separator, remainder = normalized.partition("/")
|
||||
normalized = remainder if separator else ""
|
||||
|
||||
# Remove surrounding parentheses/whitespace used by some CVSS v2 strings.
|
||||
normalized = normalized.strip().strip("()").strip()
|
||||
if not normalized:
|
||||
return metrics
|
||||
|
||||
# Parse all vector formats with shared key/value extraction logic.
|
||||
for part in normalized.split("/"):
|
||||
if ":" in part:
|
||||
key, value = part.split(":", 1)
|
||||
metrics[key] = value
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def analyze_attack_vector(cvss_metrics: dict[str, str]) -> dict[str, Any]:
|
||||
"""
|
||||
Analyze attack vector from CVSS metrics.
|
||||
|
||||
Args:
|
||||
cvss_metrics: Parsed CVSS metrics dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary with attack vector analysis
|
||||
"""
|
||||
analysis = {
|
||||
"is_network_accessible": False,
|
||||
"requires_authentication": True,
|
||||
"requires_user_interaction": True,
|
||||
"complexity": "unknown"
|
||||
}
|
||||
|
||||
# Attack Vector (AV)
|
||||
av = cvss_metrics.get("AV", "")
|
||||
if av == "N": # Network
|
||||
analysis["is_network_accessible"] = True
|
||||
elif av == "A": # Adjacent Network
|
||||
analysis["is_network_accessible"] = True
|
||||
elif av in ["L", "P"]: # Local or Physical
|
||||
analysis["is_network_accessible"] = False
|
||||
|
||||
# Privileges Required (PR) / Authentication (AU for v2)
|
||||
pr = cvss_metrics.get("PR", cvss_metrics.get("Au", ""))
|
||||
if pr in ["N", "NONE"]:
|
||||
analysis["requires_authentication"] = False
|
||||
elif pr in ["L", "H", "SINGLE", "MULTIPLE"]:
|
||||
analysis["requires_authentication"] = True
|
||||
|
||||
# User Interaction (UI)
|
||||
ui = cvss_metrics.get("UI", "")
|
||||
if ui == "N": # None
|
||||
analysis["requires_user_interaction"] = False
|
||||
elif ui == "R": # Required
|
||||
analysis["requires_user_interaction"] = True
|
||||
|
||||
# Attack Complexity (AC)
|
||||
ac = cvss_metrics.get("AC", "")
|
||||
if ac == "L":
|
||||
analysis["complexity"] = "low"
|
||||
elif ac in ["M", "H"]:
|
||||
analysis["complexity"] = "high"
|
||||
|
||||
return analysis
|
||||
|
||||
|
||||
def detect_exploit_availability(references: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
Detect if exploits are publicly available based on reference URLs.
|
||||
|
||||
Args:
|
||||
references: List of reference URLs
|
||||
|
||||
Returns:
|
||||
Dictionary with exploit_available (bool) and exploit_sources (list)
|
||||
"""
|
||||
exploit_indicators = [
|
||||
"exploit-db.com",
|
||||
"exploit-database",
|
||||
"exploitdb",
|
||||
"packetstormsecurity.com",
|
||||
"packetstorm",
|
||||
"github.com/exploit",
|
||||
"github.com/poc",
|
||||
"github.com/proof-of-concept",
|
||||
"metasploit",
|
||||
"exploit/",
|
||||
"/exploit",
|
||||
"/poc",
|
||||
"/proof-of-concept",
|
||||
"exploitability",
|
||||
"exploit-code",
|
||||
]
|
||||
|
||||
exploit_sources = []
|
||||
for ref in references:
|
||||
ref_lower = ref.lower()
|
||||
for indicator in exploit_indicators:
|
||||
if indicator in ref_lower:
|
||||
exploit_sources.append(ref)
|
||||
break
|
||||
|
||||
return {
|
||||
"exploit_available": len(exploit_sources) > 0,
|
||||
"exploit_sources": exploit_sources
|
||||
}
|
||||
|
||||
|
||||
def analyze_exploitability(cve_data: dict[str, Any], check_exploits: bool = False) -> dict[str, Any]:
|
||||
"""
|
||||
Analyze CVE exploitability for OpenClaw/NanoClaw deployments.
|
||||
|
||||
Args:
|
||||
cve_data: Dictionary containing CVE information with keys:
|
||||
- cve_id: CVE identifier
|
||||
- cvss_score: CVSS base score (float)
|
||||
- cvss_vector: CVSS vector string (optional)
|
||||
- type: Vulnerability type
|
||||
- description: CVE description text
|
||||
- references: List of reference URLs (optional)
|
||||
check_exploits: Whether to check references for exploit availability
|
||||
|
||||
Returns:
|
||||
Dictionary with exploitability_score (high/medium/low/unknown) and rationale
|
||||
"""
|
||||
cve_id = cve_data.get("cve_id", "unknown")
|
||||
cvss_score = cve_data.get("cvss_score", 0.0)
|
||||
cvss_vector = cve_data.get("cvss_vector", "")
|
||||
vuln_type = cve_data.get("type", "")
|
||||
description = cve_data.get("description", "")
|
||||
references = cve_data.get("references", [])
|
||||
|
||||
# Parse CVSS vector if available
|
||||
cvss_metrics = parse_cvss_vector(cvss_vector)
|
||||
attack_analysis = analyze_attack_vector(cvss_metrics)
|
||||
|
||||
# Initial scoring based on CVSS
|
||||
score = "unknown"
|
||||
rationale_parts = []
|
||||
|
||||
# CVSS-based baseline
|
||||
if cvss_score >= 9.0:
|
||||
score = "high"
|
||||
rationale_parts.append(f"Critical CVSS score ({cvss_score})")
|
||||
elif cvss_score >= 7.0:
|
||||
score = "high"
|
||||
rationale_parts.append(f"High CVSS score ({cvss_score})")
|
||||
elif cvss_score >= 4.0:
|
||||
score = "medium"
|
||||
rationale_parts.append(f"Medium CVSS score ({cvss_score})")
|
||||
elif cvss_score > 0:
|
||||
score = "low"
|
||||
rationale_parts.append(f"Low CVSS score ({cvss_score})")
|
||||
else:
|
||||
score = "unknown"
|
||||
rationale_parts.append("No CVSS score available")
|
||||
|
||||
# Adjust based on attack vector analysis
|
||||
if attack_analysis["is_network_accessible"]:
|
||||
if not attack_analysis["requires_authentication"] and not attack_analysis["requires_user_interaction"]:
|
||||
# Network accessible, no auth, no user interaction = highly exploitable
|
||||
if score == "medium":
|
||||
score = "high"
|
||||
rationale_parts.append("remotely exploitable without authentication")
|
||||
else:
|
||||
rationale_parts.append("network accessible")
|
||||
else:
|
||||
# Local-only vulnerabilities are less critical in agent deployments
|
||||
if score == "high":
|
||||
score = "medium"
|
||||
rationale_parts.append("requires local access")
|
||||
|
||||
# OpenClaw/NanoClaw deployment context - adjust based on vulnerability type
|
||||
vuln_type_lower = vuln_type.lower()
|
||||
description_lower = description.lower()
|
||||
|
||||
# High-risk vulnerability types in AI agent deployments
|
||||
if any(keyword in vuln_type_lower or keyword in description_lower for keyword in [
|
||||
"ssrf", "server_side_request_forgery", "server-side request forgery"
|
||||
]):
|
||||
# SSRF is critical for agents that make external API calls
|
||||
if score != "high" and cvss_score >= 6.0:
|
||||
score = "high"
|
||||
rationale_parts.append("SSRF affects agents making external requests")
|
||||
|
||||
elif any(keyword in vuln_type_lower or keyword in description_lower for keyword in [
|
||||
"path_traversal", "path traversal", "directory traversal", "file_inclusion"
|
||||
]):
|
||||
# Path traversal is critical for agents with file system access
|
||||
if score != "high" and cvss_score >= 6.0:
|
||||
score = "high"
|
||||
rationale_parts.append("path traversal affects agents with file access")
|
||||
|
||||
elif any(keyword in vuln_type_lower or keyword in description_lower for keyword in [
|
||||
"rce", "remote_code_execution", "remote code execution", "code_injection",
|
||||
"command_injection", "command injection", "arbitrary code"
|
||||
]):
|
||||
# RCE is always critical regardless of other factors
|
||||
score = "high"
|
||||
rationale_parts.append("RCE is critical in agent deployments")
|
||||
|
||||
elif any(keyword in vuln_type_lower or keyword in description_lower for keyword in [
|
||||
"prototype_pollution", "prototype pollution"
|
||||
]):
|
||||
# Prototype pollution in Node.js agents can lead to RCE
|
||||
if score == "low":
|
||||
score = "medium"
|
||||
rationale_parts.append("prototype pollution can escalate in Node.js agents")
|
||||
|
||||
elif any(keyword in vuln_type_lower or keyword in description_lower for keyword in [
|
||||
"xss", "cross_site_scripting", "cross-site scripting", "reflected xss", "stored xss"
|
||||
]):
|
||||
# XSS is lower risk in headless agent deployments (no browser rendering)
|
||||
if score == "high" and not attack_analysis["is_network_accessible"]:
|
||||
score = "medium"
|
||||
rationale_parts.append("XSS has limited impact in headless agents")
|
||||
|
||||
elif any(keyword in vuln_type_lower or keyword in description_lower for keyword in [
|
||||
"sql_injection", "sql injection", "nosql injection"
|
||||
]):
|
||||
# SQL injection depends on whether agent uses databases
|
||||
if attack_analysis["is_network_accessible"] and not attack_analysis["requires_authentication"]:
|
||||
if score == "medium":
|
||||
score = "high"
|
||||
rationale_parts.append("injection affects agents with database access")
|
||||
|
||||
# Check for exploit availability if requested
|
||||
exploit_info = {"exploit_available": False, "exploit_sources": []}
|
||||
if check_exploits and references:
|
||||
exploit_info = detect_exploit_availability(references)
|
||||
if exploit_info["exploit_available"]:
|
||||
# Elevate score if public exploits exist
|
||||
if score == "low":
|
||||
score = "medium"
|
||||
elif score == "medium":
|
||||
score = "high"
|
||||
elif score == "unknown" and cvss_score > 0:
|
||||
# If we have some CVSS score but it was unknown, upgrade to at least medium
|
||||
score = "medium"
|
||||
|
||||
exploit_count = len(exploit_info["exploit_sources"])
|
||||
source_suffix = "s" if exploit_count > 1 else ""
|
||||
rationale_parts.append(
|
||||
f"public exploit available ({exploit_count} source{source_suffix})"
|
||||
)
|
||||
|
||||
# Build rationale string
|
||||
rationale = "; ".join(rationale_parts[:5]) # Limit to first 5 parts for context
|
||||
|
||||
result = {
|
||||
"cve_id": cve_id,
|
||||
"exploitability_score": score,
|
||||
"exploitability_rationale": rationale,
|
||||
"attack_vector_analysis": attack_analysis
|
||||
}
|
||||
|
||||
# Include exploit info if check_exploits was enabled
|
||||
if check_exploits:
|
||||
result["exploit_detection"] = exploit_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def run_test_cases():
|
||||
"""
|
||||
Run comprehensive test cases for attack vector analysis.
|
||||
Tests CVSS vector parsing and attack vector analysis logic.
|
||||
"""
|
||||
test_cases = [
|
||||
{
|
||||
"name": "CVSS 3.1 - Network accessible, no auth, no UI (critical)",
|
||||
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"expected": {
|
||||
"is_network_accessible": True,
|
||||
"requires_authentication": False,
|
||||
"requires_user_interaction": False,
|
||||
"complexity": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CVSS 3.1 - Network accessible, requires auth",
|
||||
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
|
||||
"expected": {
|
||||
"is_network_accessible": True,
|
||||
"requires_authentication": True,
|
||||
"requires_user_interaction": False,
|
||||
"complexity": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CVSS 3.1 - Network accessible, requires UI",
|
||||
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
|
||||
"expected": {
|
||||
"is_network_accessible": True,
|
||||
"requires_authentication": False,
|
||||
"requires_user_interaction": True,
|
||||
"complexity": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CVSS 3.1 - Local access required",
|
||||
"cvss_vector": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"expected": {
|
||||
"is_network_accessible": False,
|
||||
"requires_authentication": False,
|
||||
"requires_user_interaction": False,
|
||||
"complexity": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CVSS 3.1 - Adjacent network, high auth",
|
||||
"cvss_vector": "CVSS:3.1/AV:A/AC:H/PR:H/UI:R/S:U/C:L/I:L/A:L",
|
||||
"expected": {
|
||||
"is_network_accessible": True,
|
||||
"requires_authentication": True,
|
||||
"requires_user_interaction": True,
|
||||
"complexity": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CVSS 3.0 - Physical access required",
|
||||
"cvss_vector": "CVSS:3.0/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"expected": {
|
||||
"is_network_accessible": False,
|
||||
"requires_authentication": False,
|
||||
"requires_user_interaction": False,
|
||||
"complexity": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CVSS v2 - Network, no auth required",
|
||||
"cvss_vector": "(AV:N/AC:L/Au:N/C:C/I:C/A:C)",
|
||||
"expected": {
|
||||
"is_network_accessible": True,
|
||||
"requires_authentication": False,
|
||||
"requires_user_interaction": True, # v2 doesn't have UI, defaults to True
|
||||
"complexity": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CVSS v2 - Network, single auth",
|
||||
"cvss_vector": "AV:N/AC:M/Au:SINGLE/C:P/I:P/A:P",
|
||||
"expected": {
|
||||
"is_network_accessible": True,
|
||||
"requires_authentication": True,
|
||||
"requires_user_interaction": True, # v2 doesn't have UI, defaults to True
|
||||
"complexity": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CVSS v2 - Local access, multiple auth",
|
||||
"cvss_vector": "(AV:L/AC:L/Au:MULTIPLE/C:C/I:C/A:C)",
|
||||
"expected": {
|
||||
"is_network_accessible": False,
|
||||
"requires_authentication": True,
|
||||
"requires_user_interaction": True, # v2 doesn't have UI, defaults to True
|
||||
"complexity": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Empty CVSS vector",
|
||||
"cvss_vector": "",
|
||||
"expected": {
|
||||
"is_network_accessible": False,
|
||||
"requires_authentication": True,
|
||||
"requires_user_interaction": True,
|
||||
"complexity": "unknown"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
print("Running attack vector analysis test cases...")
|
||||
print("=" * 70)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for i, test in enumerate(test_cases, 1):
|
||||
print(f"\nTest {i}/{len(test_cases)}: {test['name']}")
|
||||
print(f" CVSS Vector: {test['cvss_vector']}")
|
||||
|
||||
# Parse CVSS vector and analyze attack vector
|
||||
cvss_metrics = parse_cvss_vector(test['cvss_vector'])
|
||||
result = analyze_attack_vector(cvss_metrics)
|
||||
|
||||
# Compare with expected results
|
||||
test_passed = True
|
||||
for key, expected_value in test['expected'].items():
|
||||
actual_value = result.get(key)
|
||||
if actual_value != expected_value:
|
||||
print(f" ❌ FAILED: {key}")
|
||||
print(f" Expected: {expected_value}")
|
||||
print(f" Got: {actual_value}")
|
||||
test_passed = False
|
||||
failed += 1
|
||||
break
|
||||
|
||||
if test_passed:
|
||||
print(" ✓ PASSED")
|
||||
passed += 1
|
||||
else:
|
||||
# Show full result for debugging
|
||||
print(f" Full result: {json.dumps(result, indent=6)}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"Test Results: {passed} passed, {failed} failed out of {len(test_cases)} total")
|
||||
|
||||
if failed > 0:
|
||||
print("\n❌ Some tests failed!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n✅ All tests passed!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze CVE exploitability for OpenClaw/NanoClaw deployments",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Analyze from JSON stdin
|
||||
echo '{"cve_id":"CVE-2026-27488","cvss_score":7.3,"type":"ssrf"}' | python utils/analyze_exploitability.py --json
|
||||
|
||||
# Analyze with CVSS vector
|
||||
echo '{"cve_id":"CVE-2026-1234","cvss_score":9.8,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}' \
|
||||
| python utils/analyze_exploitability.py --json
|
||||
|
||||
# Run test cases
|
||||
python utils/analyze_exploitability.py --test-cases
|
||||
|
||||
# Parse CVSS vector only
|
||||
python utils/analyze_exploitability.py --parse-vector "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Read CVE data from stdin as JSON and output analysis"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--parse-vector",
|
||||
type=str,
|
||||
metavar="VECTOR",
|
||||
help="Parse and display CVSS vector string"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--test-cases",
|
||||
action="store_true",
|
||||
help="Run built-in test cases to verify analyzer logic"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--check-exploits",
|
||||
action="store_true",
|
||||
help="Check references for publicly available exploits and adjust score accordingly"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle --parse-vector
|
||||
if args.parse_vector:
|
||||
metrics = parse_cvss_vector(args.parse_vector)
|
||||
print(json.dumps(metrics, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
# Handle --test-cases
|
||||
if args.test_cases:
|
||||
run_test_cases()
|
||||
sys.exit(0)
|
||||
|
||||
# Handle --json (stdin)
|
||||
if args.json:
|
||||
try:
|
||||
cve_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = analyze_exploitability(cve_data, check_exploits=args.check_exploits)
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
# No action specified - show help
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import type { Components } from 'react-markdown';
|
||||
|
||||
export const defaultMarkdownComponents: 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>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
const FRONTMATTER_REGEX = /^---\s*\n[\s\S]*?\n---\s*\n/;
|
||||
|
||||
/**
|
||||
* Remove a leading YAML frontmatter block from markdown content.
|
||||
* @param {string} content
|
||||
* @returns {string}
|
||||
*/
|
||||
export const stripFrontmatter = (content) =>
|
||||
String(content ?? '').replace(FRONTMATTER_REGEX, '');
|
||||
|
||||
/**
|
||||
* Build a readable fallback title from a markdown file path.
|
||||
* @param {string} filePath
|
||||
* @returns {string}
|
||||
*/
|
||||
export const fallbackTitleFromPath = (filePath) => {
|
||||
const normalized = String(filePath ?? '');
|
||||
const filename = normalized.split('/').pop() ?? normalized;
|
||||
const stem = filename.replace(/\.md$/i, '');
|
||||
return stem
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (part.toUpperCase() === part && part.length > 1) return part;
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the first H1 title from markdown; fall back to path-derived title.
|
||||
* @param {string} content
|
||||
* @param {string} filePath
|
||||
* @returns {string}
|
||||
*/
|
||||
export const extractTitleFromMarkdown = (content, filePath) => {
|
||||
const cleaned = stripFrontmatter(content).trim();
|
||||
const match = cleaned.match(/^#\s+(.+)$/m);
|
||||
return match?.[1]?.trim() || fallbackTitleFromPath(filePath);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Normalize a wiki slug for route/path construction.
|
||||
* @param {string} slug
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalizeWikiSlug = (slug) =>
|
||||
String(slug ?? '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
/**
|
||||
* Return whether a slug represents the wiki index page.
|
||||
* @param {string} slug
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWikiIndexSlug = (slug) => normalizeWikiSlug(slug).toLowerCase() === 'index';
|
||||
|
||||
/**
|
||||
* Convert a wiki slug to app route path.
|
||||
* @param {string} slug
|
||||
* @returns {string}
|
||||
*/
|
||||
export const toWikiRoute = (slug) => {
|
||||
const normalized = normalizeWikiSlug(slug);
|
||||
if (!normalized || isWikiIndexSlug(normalized)) return '/wiki';
|
||||
return `/wiki/${normalized}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a wiki slug to its llms.txt endpoint path.
|
||||
* @param {string} slug
|
||||
* @returns {string}
|
||||
*/
|
||||
export const toWikiLlmsPath = (slug) => {
|
||||
const normalized = normalizeWikiSlug(slug);
|
||||
if (!normalized || isWikiIndexSlug(normalized)) return '/wiki/llms.txt';
|
||||
return `/wiki/${normalized}/llms.txt`;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
# Wiki Generation Metadata
|
||||
|
||||
- Commit hash: `d5aadfbee15b48ebb4872dfb838e4df88c611d56`
|
||||
- Branch name: `codex/wiki-tab-ui`
|
||||
- Generation timestamp (local): `2026-02-26T09:16:02+0200`
|
||||
- Generation mode: `update`
|
||||
- Output language: `English`
|
||||
- Assets copied into `wiki/assets/`:
|
||||
- `overview_img_01_prompt-security-logo.png` (from `img/Black+Color.png`)
|
||||
- `overview_img_02_clawsec-mascot.png` (from `public/img/mascot.png`)
|
||||
- `architecture_img_01_prompt-line.svg` (from `public/img/prompt_line.svg`)
|
||||
|
||||
## Notes
|
||||
- Migrated root documentation pages from `docs/` into dedicated `wiki/` operation pages.
|
||||
- Updated index and cross-links to use `wiki/` as the documentation source of truth.
|
||||
- Future updates should preserve existing headings and append `Update Notes` sections when making deltas.
|
||||
|
||||
## Source References
|
||||
- README.md
|
||||
- package.json
|
||||
- AGENTS.md
|
||||
- wiki/overview.md
|
||||
- wiki/architecture.md
|
||||
- wiki/dependencies.md
|
||||
- wiki/data-flow.md
|
||||
- wiki/glossary.md
|
||||
- wiki/security-signing-runbook.md
|
||||
- wiki/migration-signed-feed.md
|
||||
- wiki/platform-verification.md
|
||||
- wiki/remediation-plan.md
|
||||
- wiki/compatibility-report.md
|
||||
@@ -0,0 +1,53 @@
|
||||
# Wiki Index
|
||||
|
||||
## Summary
|
||||
- Purpose: Document ClawSec as a combined web catalog, signed advisory channel, and multi-skill security distribution system.
|
||||
- Tech stack: React 19 + Vite + TypeScript frontend, Node/ESM scripts, Python utilities, Bash automation, GitHub Actions pipelines.
|
||||
- Entry points: `index.tsx`, `App.tsx`, `scripts/prepare-to-push.sh`, `scripts/populate-local-feed.sh`, `scripts/populate-local-skills.sh`, workflow files under `.github/workflows/`.
|
||||
- Where to start: Read [Overview](overview.md), then [Architecture](architecture.md), then module pages for the area you are editing.
|
||||
- How to navigate: Use Guides for cross-cutting concerns, Operations for runbooks and migration plans, Modules for implementation boundaries, and Source References at the end of each page to jump into code.
|
||||
|
||||
## Start Here
|
||||
- [Overview](overview.md)
|
||||
- [Architecture](architecture.md)
|
||||
|
||||
## Guides
|
||||
- [Dependencies](dependencies.md)
|
||||
- [Data Flow](data-flow.md)
|
||||
- [Configuration](configuration.md)
|
||||
- [Testing](testing.md)
|
||||
- [Workflow](workflow.md)
|
||||
- [Security](security.md)
|
||||
|
||||
## Operations
|
||||
- [Security Signing Runbook](security-signing-runbook.md)
|
||||
- [Signed Feed Migration Plan](migration-signed-feed.md)
|
||||
- [Platform Verification Checklist](platform-verification.md)
|
||||
- [Cross-Platform Remediation Plan](remediation-plan.md)
|
||||
- [Cross-Platform Compatibility Report](compatibility-report.md)
|
||||
|
||||
## Modules
|
||||
- [Frontend Web App](modules/frontend-web.md)
|
||||
- [ClawSec Suite Core](modules/clawsec-suite.md)
|
||||
- [NanoClaw Integration](modules/nanoclaw-integration.md)
|
||||
- [Automation and Release Pipelines](modules/automation-release.md)
|
||||
- [Local Validation and Packaging Tools](modules/local-tooling.md)
|
||||
|
||||
## Glossary
|
||||
- [Glossary](glossary.md)
|
||||
|
||||
## Generation Metadata
|
||||
- [Generation Metadata](GENERATION.md)
|
||||
|
||||
## Update Notes
|
||||
- 2026-02-26: Added Operations pages and updated navigation guidance after migrating root docs into wiki pages.
|
||||
|
||||
## Source References
|
||||
- README.md
|
||||
- App.tsx
|
||||
- package.json
|
||||
- scripts/prepare-to-push.sh
|
||||
- scripts/populate-local-feed.sh
|
||||
- scripts/populate-local-skills.sh
|
||||
- skills/clawsec-suite/skill.json
|
||||
- .github/workflows/ci.yml
|
||||
@@ -0,0 +1,131 @@
|
||||
# Architecture
|
||||
|
||||
## System Context
|
||||
- This page appears under the `Start Here` section in `INDEX.md`.
|
||||
- ClawSec sits between upstream intelligence sources (NVD + community issues), GitHub automation, and runtime agent environments.
|
||||
- The repository publishes both static site content and signed artifacts that runtime skills verify before using.
|
||||
- External actor groups:
|
||||
- GitHub Actions runners executing CI, release, and feed workflows.
|
||||
- OpenClaw/NanoClaw agents consuming skills, advisories, and verification scripts.
|
||||
- Repository maintainers approving advisory issues and merging release/tag changes.
|
||||
|
||||
## Components
|
||||
| Component | Location | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Web UI | `App.tsx`, `pages/`, `components/` | Renders skills catalog and advisory detail experiences. |
|
||||
| Advisory Feed Core | `advisories/feed.json*`, `skills/clawsec-suite/.../feed.mjs` | Stores, verifies, and parses advisories with detached signatures/checksums. |
|
||||
| Skill Packages | `skills/*/` | Distributes installable security capabilities with SBOM metadata. |
|
||||
| Local Automation Scripts | `scripts/*.sh` | Build local mirrors, pre-push checks, and manual release helpers. |
|
||||
| CI/CD Workflows | `.github/workflows/*.yml` | Linting, tests, NVD polling, release packaging, and Pages deploy. |
|
||||
| Python Utility Layer | `utils/*.py` | Skill metadata validation and checksum generation. |
|
||||
|
||||
## Key Flows
|
||||
- Skill catalog flow:
|
||||
1. Release/tag workflows publish skill assets.
|
||||
2. Deploy workflow discovers release assets and builds `public/skills/index.json`.
|
||||
3. UI fetches `public/skills/index.json` and skill docs for `/skills` pages.
|
||||
- Advisory feed flow:
|
||||
1. `poll-nvd-cves.yml` and `community-advisory.yml` update `advisories/feed.json`.
|
||||
2. Feed is signed and mirrored to public paths.
|
||||
3. Runtime hooks/scripts load remote feed and fallback to local signed copies.
|
||||
- Guarded install flow:
|
||||
1. Installer requests target skill + version.
|
||||
2. Advisory matcher checks affected specifiers and severity/risk hints.
|
||||
3. Exit code 42 enforces second confirmation when advisories match.
|
||||
|
||||
## Diagrams
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["NVD + Community Inputs"] --> B["Feed Workflows\n(poll/community)"]
|
||||
B --> C["advisories/feed.json + signatures"]
|
||||
C --> D["Deploy Workflow Mirrors to public/"]
|
||||
D --> E["React UI (catalog/feed pages)"]
|
||||
C --> F["clawsec-suite hook + installers"]
|
||||
F --> G["Agent advisory alerts / gated install"]
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Interfaces and Contracts
|
||||
| Interface | Contract Form | Validation |
|
||||
| --- | --- | --- |
|
||||
| Skill metadata | `skills/*/skill.json` | Validated by Python utility + CI version-parity checks. |
|
||||
| Advisory feed | JSON + Ed25519 detached signature | Verified by `feed.mjs` and NanoClaw signature utilities. |
|
||||
| Checksums manifest | `checksums.json` (+ optional `.sig`) | Parsed and hash-matched before trusting payloads. |
|
||||
| Hook event interface | `HookEvent` (`type`, `action`, `messages`) | Runtime handler only processes selected event names. |
|
||||
| Workflow release naming | Tag pattern `<skill>-vX.Y.Z` | Parsed in release/deploy workflows to discover skills. |
|
||||
|
||||
## Key Parameters
|
||||
| Parameter | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `CLAWSEC_FEED_URL` | `https://clawsec.prompt.security/advisories/feed.json` | Remote advisory source for suite scripts/hooks. |
|
||||
| `CLAWSEC_ALLOW_UNSIGNED_FEED` | `0` | Enables temporary unsigned fallback compatibility. |
|
||||
| `CLAWSEC_VERIFY_CHECKSUM_MANIFEST` | `1` | Requires checksum manifest verification where available. |
|
||||
| `CLAWSEC_HOOK_INTERVAL_SECONDS` | `300` | Scan throttling window for advisory hook. |
|
||||
| `CLAWSEC_SKILLS_INDEX_TIMEOUT_MS` | `5000` | Remote skill index fetch timeout for catalog discovery. |
|
||||
| `PROMPTSEC_GIT_PULL` | `0` | Optional auto-pull before watchdog audit runs. |
|
||||
|
||||
## Error Handling and Reliability
|
||||
- Feed fetching is fail-closed for invalid signatures and malformed manifests.
|
||||
- Remote fetch failures gracefully fall back to local signed feeds.
|
||||
- Hook state uses atomic file writes with strict mode where supported.
|
||||
- UI pages detect HTML fallbacks served as JSON and avoid rendering corrupted data.
|
||||
- Workflow steps enforce key-fingerprint consistency to avoid split-key drift.
|
||||
|
||||
## Example Snippets
|
||||
```tsx
|
||||
// Route topology in the web app
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/skills" element={<SkillsCatalog />} />
|
||||
<Route path="/skills/:skillId" element={<SkillDetail />} />
|
||||
<Route path="/feed" element={<FeedSetup />} />
|
||||
<Route path="/feed/:advisoryId" element={<AdvisoryDetail />} />
|
||||
<Route path="/wiki/*" element={<WikiBrowser />} />
|
||||
</Routes>
|
||||
```
|
||||
|
||||
```ts
|
||||
// Guarded feed loading contract in advisory hook
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl, {
|
||||
signatureUrl: feedSignatureUrl,
|
||||
checksumsUrl: feedChecksumsUrl,
|
||||
checksumsSignatureUrl: feedChecksumsSignatureUrl,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
});
|
||||
```
|
||||
|
||||
## Runtime and Deployment
|
||||
| Runtime Surface | Execution Model | Output |
|
||||
| --- | --- | --- |
|
||||
| Vite app (`npm run dev`) | Local frontend server | Interactive web app for feed/skills. |
|
||||
| GitHub CI | Multi-OS matrix + dedicated jobs | Lint/type/build/security and test confidence. |
|
||||
| Skill release workflow | Tag-driven publish + PR dry-run checks | Release assets, signed checksums, optional ClawHub publish. |
|
||||
| Pages deploy workflow | Triggered by CI/Release success | Static site + mirrored advisories/releases. |
|
||||
| Runtime hooks | OpenClaw event hooks / NanoClaw IPC | Advisory alerts, gating decisions, integrity checks. |
|
||||
|
||||
## Scaling Notes
|
||||
- Advisory volume scales with keyword set in NVD polling; dedupe and post-filtering control noise.
|
||||
- Deploy workflow processes release lists and keeps newest skill versions in index output.
|
||||
- Module boundaries by skill folder allow adding new security capabilities without changing frontend structure.
|
||||
- Signature verification paths remain lightweight because payload sizes (feed/manifests) are small.
|
||||
|
||||
## Source References
|
||||
- App.tsx
|
||||
- pages/SkillsCatalog.tsx
|
||||
- pages/FeedSetup.tsx
|
||||
- pages/AdvisoryDetail.tsx
|
||||
- pages/WikiBrowser.tsx
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs
|
||||
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
|
||||
- skills/clawsec-suite/scripts/discover_skill_catalog.mjs
|
||||
- skills/clawsec-nanoclaw/lib/advisories.ts
|
||||
- skills/clawsec-nanoclaw/lib/signatures.ts
|
||||
- .github/workflows/poll-nvd-cves.yml
|
||||
- .github/workflows/community-advisory.yml
|
||||
- .github/workflows/deploy-pages.yml
|
||||
- .github/workflows/skill-release.yml
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1455.1 1298.3">
|
||||
<!-- Generator: Adobe Illustrator 29.0.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 186) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: none;
|
||||
stroke: #fff;
|
||||
stroke-width: 4px;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
opacity: .1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g class="st1">
|
||||
<path class="st0" d="M730,505.6l209.7,362.8M730,505.6l-209.6,362.8M730,505.6l-64.5-111.7c-26.1-45.2-91.4-45.2-117.5,0l-405.4,701.7c-23.9,41.4-23.9,92.4,0,133.8M939.7,868.4h-419.3M939.7,868.4h144.1c44.8,0,72.9-48.5,50.5-87.3l-281.7-487.6M520.4,868.4h563.4c44.8,0,72.9-48.5,50.5-87.3l-281.7-487.6M520.4,868.4l-73.2,126.6c-22.4,38.8,5.6,87.3,50.5,87.3h818.1M852.6,293.5l-102.3-177.1M852.6,293.5l-102.3-177.1h0M750.2,116.4l-27.3-47.2C698.8,27.6,654.4,1.9,606.3,1.9M606.4,2h245.3c48.5,0,93.2,25.8,117.5,67.8l465.9,806.4c24.2,41.9,24.2,93.6,0,135.5l-1.7,2.9M606.4,2c-48.1,0-92.6,25.6-116.6,67.3L20.2,882c-24.2,41.9-24.2,93.6,0,135.5l122.4,211.9M1315.8,1082.3c43.9,0,85-21.4,110.1-57.3l7.3-10.5M1315.8,1082.3c48.5,0,93.2-25.9,117.5-67.8M1433.3,1014.6h0l-39.2,67.8h0l-84.5,146.2c-24.2,41.9-69,67.8-117.4,67.8H258.5c-47.8,0-92-25.5-115.9-66.9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 788 KiB |
@@ -34,7 +34,7 @@ This could produce paths like `~/.openclaw/workspace/$HOME/...`.
|
||||
| CP-006 | High | Windows | Multiple SKILL docs and shell scripts | Install/maintenance flow is still heavily POSIX-shell based. | Add PowerShell equivalents or Node wrappers for critical flows. | Open |
|
||||
| CP-007 | Medium | Linux/macOS/Windows | `skills/soul-guardian/scripts/soul_guardian.py` | `Path(...).expanduser()` handles `~` but not `$HOME`/`%USERPROFILE%`. | Add explicit env-token expansion + validation for `--state-dir`. | Open |
|
||||
| CP-008 | Medium | Windows | `scripts/release-skill.sh`, `scripts/populate-local-*.sh` | GNU/BSD shell toolchain assumptions block native Windows usage. | Provide cross-platform Node/Python replacements or PowerShell equivalents. | Open |
|
||||
| CP-009 | Low | Windows | docs + scripts using `chmod 600/644` | POSIX permission semantics are partial/non-portable on Windows. | Document best-effort behavior and Windows ACL alternatives. | Open |
|
||||
| CP-009 | Low | Windows | documentation + scripts using `chmod 600/644` | POSIX permission semantics are partial/non-portable on Windows. | Document best-effort behavior and Windows ACL alternatives. | Open |
|
||||
| CP-010 | Low | macOS/Windows | CI non-Node jobs | Shell/Python/security scan jobs remain Ubuntu-only. | Add scoped matrix or dedicated non-Linux smoke jobs where practical. | Open |
|
||||
|
||||
---
|
||||
@@ -54,7 +54,7 @@ This could produce paths like `~/.openclaw/workspace/$HOME/...`.
|
||||
## Permissions / Filesystem Semantics
|
||||
- Confirmed many scripts rely on POSIX permission commands.
|
||||
- Existing `state.ts` already handles `chmod` failures on unsupported filesystems.
|
||||
- Open: docs still mostly assume POSIX permissions.
|
||||
- Open: documentation still mostly assumes POSIX permissions.
|
||||
|
||||
## Line Endings
|
||||
- Fixed by adding `.gitattributes` with LF rules for scripts and key text/config files.
|
||||
@@ -62,7 +62,7 @@ This could produce paths like `~/.openclaw/workspace/$HOME/...`.
|
||||
## Runtime Dependencies
|
||||
- Node scripts generally portable.
|
||||
- Python utilities are portable.
|
||||
- OpenSSL usage in docs/workflows remains shell/toolchain dependent.
|
||||
- OpenSSL usage in documentation/workflows remains shell/toolchain dependent.
|
||||
|
||||
## CI / Automation
|
||||
- Fixed: TS/lint/build matrix now runs on Linux/macOS/Windows.
|
||||
@@ -95,3 +95,17 @@ This could produce paths like `~/.openclaw/workspace/$HOME/...`.
|
||||
- `sh` (where scripts are invoked through Node entrypoints): same path behavior in Node layer.
|
||||
- Windows PowerShell: `%USERPROFILE%` / `$env:USERPROFILE` expansion and path normalization validated in Node tests.
|
||||
|
||||
## Source References
|
||||
- .gitattributes
|
||||
- .github/workflows/ci.yml
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/suppression.mjs
|
||||
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
|
||||
- skills/openclaw-audit-watchdog/scripts/setup_cron.mjs
|
||||
- skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs
|
||||
- skills/soul-guardian/scripts/soul_guardian.py
|
||||
- scripts/release-skill.sh
|
||||
- scripts/populate-local-feed.sh
|
||||
- scripts/populate-local-skills.sh
|
||||
- wiki/remediation-plan.md
|
||||
- wiki/platform-verification.md
|
||||
@@ -0,0 +1,88 @@
|
||||
# Configuration
|
||||
|
||||
## Scope
|
||||
- Configuration spans frontend build settings, runtime feed paths, workflow triggers, and skill metadata contracts.
|
||||
- Most runtime-sensitive controls are environment variables prefixed with `CLAWSEC_` or `OPENCLAW_`.
|
||||
- Path normalization is security-sensitive and intentionally rejects unresolved home-token literals.
|
||||
|
||||
## Core Runtime Variables
|
||||
| Variable | Default | Used By |
|
||||
| --- | --- | --- |
|
||||
| `CLAWSEC_FEED_URL` | Hosted advisory URL | Suite hook and guarded installer feed loading. |
|
||||
| `CLAWSEC_FEED_SIG_URL` | `<feed>.sig` | Detached signature source. |
|
||||
| `CLAWSEC_FEED_CHECKSUMS_URL` | `checksums.json` near feed URL | Optional checksum-manifest source. |
|
||||
| `CLAWSEC_FEED_PUBLIC_KEY` | Suite-local PEM file | Feed signature verification. |
|
||||
| `CLAWSEC_ALLOW_UNSIGNED_FEED` | `0` | Temporary migration bypass flag. |
|
||||
| `CLAWSEC_VERIFY_CHECKSUM_MANIFEST` | `1` | Enables checksum-manifest verification. |
|
||||
| `CLAWSEC_HOOK_INTERVAL_SECONDS` | `300` | Advisory hook scan throttle. |
|
||||
|
||||
## Path Resolution Rules
|
||||
| Rule | Behavior | Enforcement Location |
|
||||
| --- | --- | --- |
|
||||
| `~` expansion | Resolved to detected home directory | Shared path utility functions in suite/watchdog scripts. |
|
||||
| `$HOME` / `${HOME}` expansion | Resolved when unescaped | Same utilities. |
|
||||
| Windows home tokens | `%USERPROFILE%`, `$env:USERPROFILE` normalized | Same utilities. |
|
||||
| Escaped tokens (`\$HOME`) | Rejected with explicit error | Prevents accidental literal directory creation. |
|
||||
| Invalid explicit path | Can fallback to default path with warning | `resolveConfiguredPath` helpers. |
|
||||
|
||||
## Frontend and Build Configuration
|
||||
- `vite.config.ts` defines port (`3000`), host (`0.0.0.0`), and path alias (`@`).
|
||||
- `index.html` provides Tailwind runtime config, custom fonts, and base color tokens.
|
||||
- `tsconfig.json` uses bundler module resolution, `noEmit`, and JSX runtime configuration.
|
||||
- `eslint.config.js` applies TS, React, hooks, and script-specific lint rules.
|
||||
|
||||
## Skill Metadata Configuration
|
||||
| Field Group | Location | Function |
|
||||
| --- | --- | --- |
|
||||
| Core skill identity | `skills/*/skill.json` | Name/version/author/license/description metadata. |
|
||||
| SBOM file list | `skill.json -> sbom.files` | Declares release-required artifacts. |
|
||||
| Platform metadata | `openclaw` or `nanoclaw` blocks | CLI requirements, triggers, platform capability hints. |
|
||||
| Suite catalog metadata | `skills/clawsec-suite/skill.json -> catalog` | Integrated/default/consent behavior for suite members. |
|
||||
|
||||
## Workflow Configuration
|
||||
- Schedule configuration exists in workflow `cron` entries (`poll-nvd-cves`, `codeql`, `scorecard`).
|
||||
- Release workflow expects tag naming pattern `<skill>-v<semver>`.
|
||||
- Deployment workflow is triggered by successful CI/release `workflow_run` events and manual dispatch.
|
||||
- Composite signing action requires private key inputs and verifies signatures immediately after signing.
|
||||
|
||||
## Example Snippets
|
||||
```bash
|
||||
# run guarded install with explicit local signed feed paths
|
||||
CLAWSEC_LOCAL_FEED="$HOME/.openclaw/skills/clawsec-suite/advisories/feed.json" \
|
||||
CLAWSEC_LOCAL_FEED_SIG="$HOME/.openclaw/skills/clawsec-suite/advisories/feed.json.sig" \
|
||||
CLAWSEC_FEED_PUBLIC_KEY="$HOME/.openclaw/skills/clawsec-suite/advisories/feed-signing-public.pem" \
|
||||
node skills/clawsec-suite/scripts/guarded_skill_install.mjs --skill clawtributor --dry-run
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "example-skill",
|
||||
"version": "1.2.3",
|
||||
"sbom": {
|
||||
"files": [
|
||||
{ "path": "SKILL.md", "required": true, "description": "Install docs" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Operational Notes
|
||||
- Keep signing keys outside the repository and inject via GitHub Secrets only.
|
||||
- Prefer absolute paths or unescaped home expressions in local environment variable overrides.
|
||||
- Treat unsigned feed mode as temporary migration support, not normal operation.
|
||||
- Re-run release-link validation when editing `SKILL.md` URLs to avoid broken artifact references.
|
||||
|
||||
## Source References
|
||||
- vite.config.ts
|
||||
- index.html
|
||||
- tsconfig.json
|
||||
- eslint.config.js
|
||||
- skills/clawsec-suite/skill.json
|
||||
- skills/clawsec-nanoclaw/skill.json
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/utils.mjs
|
||||
- skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs
|
||||
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
|
||||
- scripts/validate-release-links.sh
|
||||
- .github/workflows/poll-nvd-cves.yml
|
||||
- .github/workflows/skill-release.yml
|
||||
- .github/actions/sign-and-verify/action.yml
|
||||
@@ -0,0 +1,98 @@
|
||||
# Data Flow
|
||||
|
||||
## Primary Flows
|
||||
- `Advisory ingestion`: NVD/community inputs are transformed into a normalized advisory feed, signed, then mirrored for clients.
|
||||
- `Skill catalog publication`: release assets are discovered and converted into `public/skills/index.json` plus per-skill docs/checksums.
|
||||
- `Runtime enforcement`: suite and nanoclaw consumers load advisory data, match against skills, and emit alerts or confirmation gates.
|
||||
- This page appears under the `Guides` section in `INDEX.md`.
|
||||
|
||||
## Step-by-Step
|
||||
1. Feed producer workflow/script fetches source data (`NVD API` or issue payload).
|
||||
2. JSON transform logic normalizes severity/type/affected fields and deduplicates by advisory ID.
|
||||
3. Signature/checksum steps generate detached signatures and checksum manifests.
|
||||
4. Deploy workflow mirrors signed artifacts under `public/` and `public/releases/latest/download/`.
|
||||
5. UI consumers validate JSON shape/content; runtime consumers additionally verify signatures/checksums before trusting feed data.
|
||||
6. Matchers compare `affected` specifiers to skill names/versions and emit alerts or enforce confirmation.
|
||||
|
||||
## Inputs and Outputs
|
||||
Inputs/outputs are summarized in the table below.
|
||||
|
||||
| Type | Name | Location | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| Input | CVE payloads | `services.nvd.nist.gov/rest/json/cves/2.0` | Source vulnerabilities filtered by ClawSec keywords. |
|
||||
| Input | Community advisory issue | `.github/workflows/community-advisory.yml` event payload | Maintainer-approved issue transformed into advisory record. |
|
||||
| Input | Skill release assets | GitHub Releases API + assets | Used to build web catalog and mirror downloads. |
|
||||
| Input | Local config/env | `OPENCLAW_AUDIT_CONFIG`, `CLAWSEC_*` vars | Controls feed pathing, suppression, and verification behavior. |
|
||||
| Output | Advisory feed | `advisories/feed.json` | Canonical repository feed. |
|
||||
| Output | Advisory signature | `advisories/feed.json.sig` | Detached signature for feed authenticity. |
|
||||
| Output | Skill catalog index | `public/skills/index.json` | Runtime web catalog used by `/skills` pages. |
|
||||
| Output | Release checksums/signatures | `release-assets/checksums.json(.sig)` | Integrity manifest for release consumers. |
|
||||
| Output | Hook state | `~/.openclaw/clawsec-suite-feed-state.json` | Tracks scan timing and notified matches. |
|
||||
|
||||
## Data Structures
|
||||
| Structure | Key Fields | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Advisory feed record | `id`, `severity`, `type`, `affected[]`, `published` | Unit of risk data used by UI and installers. |
|
||||
| Skill metadata record | `id`, `name`, `version`, `emoji`, `tag` | Catalog row for web browsing and install commands. |
|
||||
| Checksums manifest | `schema_version`, `algorithm`, `files` | Maps file names to expected digests. |
|
||||
| Advisory state | `known_advisories`, `last_hook_scan`, `notified_matches` | Prevents repeated alerts and throttles scans. |
|
||||
| Suppression config | `enabledFor[]`, `suppressions[]` | Targeted skip list by `checkId` + `skill`. |
|
||||
|
||||
## Diagrams
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["NVD + Issue Inputs"] --> B["Transform + Deduplicate"]
|
||||
B --> C["advisories/feed.json"]
|
||||
C --> D["Sign + checksums"]
|
||||
D --> E["public/advisories + releases/latest"]
|
||||
E --> F["Web UI fetch"]
|
||||
E --> G["Suite/NanoClaw verification"]
|
||||
G --> H["Match skills + emit alerts/gates"]
|
||||
```
|
||||
|
||||
## State and Storage
|
||||
| Store | Path/Scope | Write Path |
|
||||
| --- | --- | --- |
|
||||
| Canonical advisories | `advisories/` | NVD + community workflows and local populate script. |
|
||||
| Embedded advisory copies | `skills/clawsec-feed/advisories/` and `skills/clawsec-suite/advisories/` | Sync/packaging processes and release workflow. |
|
||||
| Public mirrors | `public/advisories/`, `public/releases/` | Deploy workflow. |
|
||||
| Runtime state | `~/.openclaw/clawsec-suite-feed-state.json` | Advisory hook state persistence. |
|
||||
| NanoClaw cache | `/workspace/project/data/clawsec-advisory-cache.json` | Host-side advisory cache manager. |
|
||||
| Integrity state | `/workspace/project/data/soul-guardian/` (NanoClaw) | Integrity monitor baseline/audit storage. |
|
||||
|
||||
## Example Snippets
|
||||
```bash
|
||||
# Local feed flow (NVD fetch -> transform -> sync)
|
||||
./scripts/populate-local-feed.sh --days 120
|
||||
jq '.updated, (.advisories | length)' advisories/feed.json
|
||||
```
|
||||
|
||||
```bash
|
||||
# Runtime guarded install uses signed feed paths
|
||||
CLAWSEC_LOCAL_FEED=~/.openclaw/skills/clawsec-suite/advisories/feed.json \
|
||||
CLAWSEC_FEED_PUBLIC_KEY=~/.openclaw/skills/clawsec-suite/advisories/feed-signing-public.pem \
|
||||
node skills/clawsec-suite/scripts/guarded_skill_install.mjs --skill test-skill --dry-run
|
||||
```
|
||||
|
||||
## Failure Modes
|
||||
- NVD rate limits (`403/429`) can delay feed refresh and require retries/backoff.
|
||||
- Missing or invalid detached signatures cause feed rejection in fail-closed mode.
|
||||
- HTML fallback responses for JSON endpoints can produce false positives unless explicitly filtered.
|
||||
- Path-token misconfiguration (`\$HOME`) can break local fallback path resolution.
|
||||
- Mismatched public key fingerprints in workflows trigger hard CI failure.
|
||||
|
||||
## Source References
|
||||
- advisories/feed.json
|
||||
- advisories/feed.json.sig
|
||||
- scripts/populate-local-feed.sh
|
||||
- scripts/populate-local-skills.sh
|
||||
- .github/workflows/poll-nvd-cves.yml
|
||||
- .github/workflows/community-advisory.yml
|
||||
- .github/workflows/deploy-pages.yml
|
||||
- .github/workflows/skill-release.yml
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/state.ts
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/matching.ts
|
||||
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
|
||||
- skills/clawsec-nanoclaw/lib/advisories.ts
|
||||
- skills/clawsec-nanoclaw/host-services/advisory-cache.ts
|
||||
@@ -0,0 +1,101 @@
|
||||
# Dependencies
|
||||
|
||||
## Build and Runtime
|
||||
| Layer | Primary Dependencies | Why It Exists |
|
||||
| --- | --- | --- |
|
||||
| Frontend runtime | `react`, `react-dom`, `react-router-dom`, `lucide-react` | UI rendering, routing, iconography. |
|
||||
| Markdown rendering | `react-markdown`, `remark-gfm` | Render skill docs/readmes and in-app wiki markdown pages. |
|
||||
| Build tooling | `vite`, `@vitejs/plugin-react`, `typescript` | Fast TS/TSX bundling and production builds. |
|
||||
| Python utilities | stdlib + `ruff`/`bandit` policy from `pyproject.toml` | Validate/package skills and run static checks. |
|
||||
| Shell automation | `bash`, `jq`, `curl`, `openssl`, `sha256sum`/`shasum` | Feed polling, signing, checksum generation, release checks. |
|
||||
|
||||
## Dependency Details
|
||||
| Package | Version Constraint | Scope |
|
||||
| --- | --- | --- |
|
||||
| `react` / `react-dom` | `^19.2.4` | Frontend runtime |
|
||||
| `react-router-dom` | `^7.13.1` | Frontend routing |
|
||||
| `lucide-react` | `^0.575.0` | UI icon set |
|
||||
| `vite` | `^7.3.1` | Dev server + build |
|
||||
| `typescript` | `~5.8.2` | Type checking |
|
||||
| `eslint` | `^9.39.2` | JS/TS linting |
|
||||
| `@typescript-eslint/*` | `^8.55.0` / `^8.56.0` | TS lint parser/rules |
|
||||
| `fast-check` | `^4.5.3` | Property/fuzz style tests |
|
||||
|
||||
| Override | Pinned Version | Rationale |
|
||||
| --- | --- | --- |
|
||||
| `ajv` | `6.14.0` | Security and compatibility stabilization. |
|
||||
| `balanced-match` | `4.0.3` | Transitive vulnerability control. |
|
||||
| `brace-expansion` | `5.0.2` | Transitive dependency hardening. |
|
||||
| `minimatch` | `10.2.1` | Deterministic dependency behavior. |
|
||||
|
||||
## External Services
|
||||
| Service | Used By | Function |
|
||||
| --- | --- | --- |
|
||||
| NVD API (`services.nvd.nist.gov`) | `poll-nvd-cves` workflow + local feed script | Pull CVEs by keyword/date window. |
|
||||
| GitHub API | Deploy/release workflows | Discover releases, download assets, publish outputs. |
|
||||
| GitHub Pages | Deploy workflow | Serve static site and mirrored artifacts. |
|
||||
| ClawHub CLI/registry | Install scripts + optional publish jobs | Install and publish skills. |
|
||||
| Optional local SMTP/sendmail | `openclaw-audit-watchdog` scripts | Deliver audit reports by email. |
|
||||
|
||||
## Development Tools
|
||||
| Tool | Invocation | Coverage |
|
||||
| --- | --- | --- |
|
||||
| ESLint | `npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0` | Frontend and script linting. |
|
||||
| TypeScript | `npx tsc --noEmit` | Compile-time TS contract checks. |
|
||||
| Ruff | `ruff check utils/` | Python style and bug pattern checks. |
|
||||
| Bandit | `bandit -r utils/ -ll` | Python security checks. |
|
||||
| Trivy | Workflow + optional local run | FS/config vulnerability scans. |
|
||||
| Gitleaks | `scripts/prepare-to-push.sh` optional local run | Secret leak detection before push. |
|
||||
|
||||
## Example Snippets
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 120
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["__pycache__", ".venv"]
|
||||
skips = ["B101"]
|
||||
```
|
||||
|
||||
## Compatibility Notes
|
||||
- Local scripts account for macOS vs Linux differences in `date` and `stat` usage.
|
||||
- Some workflows/scripts require OpenSSL features used with Ed25519 and `pkeyutl -rawin`.
|
||||
- Windows support is strongest for Node-based tooling; POSIX shell paths may require WSL/Git Bash.
|
||||
- Feed consumers include compatibility bypasses for migration phases, but signed mode is the intended steady state.
|
||||
|
||||
## Versioning Notes
|
||||
- Skill release tags follow `<skill>-v<semver>` and are parsed by CI/deploy automation.
|
||||
- PR validation enforces version parity between `skill.json` and `SKILL.md` frontmatter for bumped skills.
|
||||
- The public skills index keeps latest discovered version per skill for UI display.
|
||||
- Signed artifact manifests (`checksums.json`) are versioned per release and include file hashes and URLs.
|
||||
|
||||
## Source References
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- pyproject.toml
|
||||
- eslint.config.js
|
||||
- tsconfig.json
|
||||
- scripts/prepare-to-push.sh
|
||||
- scripts/populate-local-feed.sh
|
||||
- scripts/populate-local-skills.sh
|
||||
- .github/workflows/ci.yml
|
||||
- .github/workflows/codeql.yml
|
||||
- .github/workflows/scorecard.yml
|
||||
- .github/workflows/poll-nvd-cves.yml
|
||||
- .github/workflows/deploy-pages.yml
|
||||
- .github/workflows/skill-release.yml
|
||||
@@ -0,0 +1,420 @@
|
||||
# Exploitability Scoring Methodology
|
||||
|
||||
## Overview
|
||||
|
||||
ClawSec's exploitability scoring system provides context-aware vulnerability assessment specifically designed for AI agent deployments (OpenClaw/NanoClaw). Unlike generic CVSS scores that treat all environments equally, our scoring considers the unique attack surface and usage patterns of AI agents to reduce alert fatigue and prioritize actionable threats.
|
||||
|
||||
## Scoring Levels
|
||||
|
||||
| Level | Severity | Meaning |
|
||||
|---|---|---|
|
||||
| `high` | Critical/High | Exploitable in typical agent deployments, immediate attention required |
|
||||
| `medium` | Medium | May be exploitable depending on configuration, warrants investigation |
|
||||
| `low` | Low | Limited exploitability in agent context, low priority |
|
||||
| `unknown` | Unknown | Insufficient data to assess exploitability |
|
||||
|
||||
## Scoring Factors
|
||||
|
||||
### 1. CVSS Base Score (Baseline)
|
||||
|
||||
The analysis starts with the CVSS base score as a foundation:
|
||||
|
||||
- **CVSS ≥ 9.0**: Critical severity → initial score `high`
|
||||
- **CVSS 7.0-8.9**: High severity → initial score `high`
|
||||
- **CVSS 4.0-6.9**: Medium severity → initial score `medium`
|
||||
- **CVSS 1.0-3.9**: Low severity → initial score `low`
|
||||
- **No CVSS**: → initial score `unknown`
|
||||
|
||||
### 2. Attack Vector Analysis (CVSS Metrics)
|
||||
|
||||
The analyzer parses CVSS v2, v3.0, and v3.1 vectors to assess:
|
||||
|
||||
#### Network Accessibility
|
||||
- **AV:N** (Network): Remotely exploitable over network
|
||||
- **AV:A** (Adjacent): Requires local network access
|
||||
- **AV:L** (Local): Requires local system access
|
||||
- **AV:P** (Physical): Requires physical access
|
||||
|
||||
**Impact on agents**: Network-accessible vulnerabilities are elevated because agents typically run as network services or make external API calls.
|
||||
|
||||
#### Authentication Requirements
|
||||
- **PR:N / Au:NONE**: No authentication required → elevates score
|
||||
- **PR:L / Au:SINGLE**: Low privileges required
|
||||
- **PR:H / Au:MULTIPLE**: High privileges required → reduces score
|
||||
|
||||
**Impact on agents**: Unauthenticated exploits are critical for publicly exposed agent APIs.
|
||||
|
||||
#### User Interaction
|
||||
- **UI:N**: No user interaction required → elevates score
|
||||
- **UI:R**: Requires user interaction → reduces score
|
||||
|
||||
**Impact on agents**: Agents often operate autonomously, so vulnerabilities requiring user interaction are less critical.
|
||||
|
||||
#### Attack Complexity
|
||||
- **AC:L**: Low complexity → elevates score
|
||||
- **AC:M / AC:H**: Medium/High complexity → neutral or reduces score
|
||||
|
||||
**Impact on agents**: Low-complexity exploits are more likely to be automated and used in mass attacks.
|
||||
|
||||
### 3. Vulnerability Type (Deployment Context)
|
||||
|
||||
ClawSec adjusts scores based on how vulnerability types affect AI agent deployments:
|
||||
|
||||
#### High-Risk Types in Agent Context
|
||||
|
||||
**Remote Code Execution (RCE)**
|
||||
```
|
||||
Score: Always HIGH
|
||||
Rationale: RCE is critical in agent deployments
|
||||
```
|
||||
AI agents execute arbitrary code as part of their function. RCE vulnerabilities allow attackers to hijack agent execution flow, exfiltrate credentials, or pivot to other systems.
|
||||
|
||||
**Server-Side Request Forgery (SSRF)**
|
||||
```
|
||||
Score: Elevated to HIGH if CVSS ≥ 6.0
|
||||
Rationale: SSRF affects agents making external requests
|
||||
```
|
||||
Agents frequently call external APIs, access internal services, and fetch remote resources. SSRF allows attackers to:
|
||||
- Access internal cloud metadata services (AWS IMDSv1, GCP metadata)
|
||||
- Pivot to internal networks
|
||||
- Exfiltrate data through DNS tunneling
|
||||
|
||||
**Path Traversal / Directory Traversal**
|
||||
```
|
||||
Score: Elevated to HIGH if CVSS ≥ 6.0
|
||||
Rationale: Path traversal affects agents with file access
|
||||
```
|
||||
Agents read files, execute scripts, and manage codebases. Path traversal enables:
|
||||
- Reading sensitive configuration files (.env, credentials)
|
||||
- Accessing SSH keys, API tokens
|
||||
- Overwriting critical system files
|
||||
|
||||
**Command Injection**
|
||||
```
|
||||
Score: Always HIGH
|
||||
Rationale: Command injection is critical in agent deployments
|
||||
```
|
||||
Similar to RCE, agents often execute shell commands to interact with systems. Command injection allows full system compromise.
|
||||
|
||||
#### Medium-Risk Types
|
||||
|
||||
**Prototype Pollution (Node.js)**
|
||||
```
|
||||
Score: Elevated from LOW to MEDIUM
|
||||
Rationale: Prototype pollution can escalate in Node.js agents
|
||||
```
|
||||
Many agent frameworks run on Node.js. Prototype pollution can lead to:
|
||||
- Bypass of authentication checks
|
||||
- Privilege escalation
|
||||
- Denial of service
|
||||
|
||||
**SQL Injection / NoSQL Injection**
|
||||
```
|
||||
Score: Elevated to HIGH if network-accessible and unauthenticated
|
||||
Rationale: Injection affects agents with database access
|
||||
```
|
||||
Agents that store conversation history, user data, or tool results in databases are vulnerable to injection attacks.
|
||||
|
||||
#### Lower-Risk Types
|
||||
|
||||
**Cross-Site Scripting (XSS)**
|
||||
```
|
||||
Score: Reduced to MEDIUM if not network-accessible
|
||||
Rationale: XSS has limited impact in headless agents
|
||||
```
|
||||
Agents typically don't render HTML in browsers, reducing XSS impact. However, XSS in agent management UIs or chat interfaces remains a concern.
|
||||
|
||||
### 4. Exploit Availability
|
||||
|
||||
When `--check-exploits` is enabled, the analyzer checks reference URLs for public exploits:
|
||||
|
||||
**Exploit Indicators:**
|
||||
- exploit-db.com / exploit-database.com
|
||||
- packetstormsecurity.com
|
||||
- github.com/exploit, github.com/poc
|
||||
- metasploit framework modules
|
||||
- URLs containing "/exploit", "/poc", "/proof-of-concept"
|
||||
|
||||
**Score Elevation:**
|
||||
- `low` → `medium` (exploit available)
|
||||
- `medium` → `high` (exploit available)
|
||||
- `unknown` → `medium` (exploit available + CVSS > 0)
|
||||
|
||||
**Rationale**: Public exploits lower the skill barrier for attackers and increase the likelihood of automated exploitation.
|
||||
|
||||
## Scoring Algorithm
|
||||
|
||||
The analyzer follows this decision tree:
|
||||
|
||||
```
|
||||
1. Parse CVSS score → set baseline (high/medium/low/unknown)
|
||||
2. Parse CVSS vector → analyze attack characteristics
|
||||
3. Adjust for attack vector:
|
||||
- Network-accessible + no auth + no UI → elevate to HIGH
|
||||
- Local-only access → reduce HIGH to MEDIUM
|
||||
4. Adjust for vulnerability type:
|
||||
- Check against agent-specific risk categories
|
||||
- Elevate or reduce score based on deployment context
|
||||
5. Check for public exploits (if enabled):
|
||||
- Elevate score if exploits detected
|
||||
6. Generate rationale explaining the final score
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Critical RCE (High Exploitability)
|
||||
|
||||
```json
|
||||
{
|
||||
"cve_id": "CVE-2024-12345",
|
||||
"cvss_score": 9.8,
|
||||
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"type": "remote_code_execution",
|
||||
"description": "Unauthenticated RCE in Express.js framework"
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis Output:**
|
||||
```json
|
||||
{
|
||||
"exploitability_score": "high",
|
||||
"exploitability_rationale": "Critical CVSS score (9.8); remotely exploitable without authentication; RCE is critical in agent deployments"
|
||||
}
|
||||
```
|
||||
|
||||
**Why HIGH**: Critical CVSS + network accessible + no auth + RCE type.
|
||||
|
||||
### Example 2: SSRF in Agent API (High Exploitability)
|
||||
|
||||
```json
|
||||
{
|
||||
"cve_id": "CVE-2024-23456",
|
||||
"cvss_score": 7.3,
|
||||
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L",
|
||||
"type": "server_side_request_forgery",
|
||||
"description": "SSRF in webhook handler allows internal network access"
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis Output:**
|
||||
```json
|
||||
{
|
||||
"exploitability_score": "high",
|
||||
"exploitability_rationale": "High CVSS score (7.3); remotely exploitable without authentication; SSRF affects agents making external requests"
|
||||
}
|
||||
```
|
||||
|
||||
**Why HIGH**: SSRF is critical for agents that make API calls (most do). Network-accessible without authentication elevates risk.
|
||||
|
||||
### Example 3: Path Traversal with Public Exploit (High Exploitability)
|
||||
|
||||
```json
|
||||
{
|
||||
"cve_id": "CVE-2024-34567",
|
||||
"cvss_score": 6.5,
|
||||
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
|
||||
"type": "path_traversal",
|
||||
"references": [
|
||||
"https://exploit-db.com/exploits/51234",
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2024-34567"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis Output (with --check-exploits):**
|
||||
```json
|
||||
{
|
||||
"exploitability_score": "high",
|
||||
"exploitability_rationale": "Medium CVSS score (6.5); network accessible; path traversal affects agents with file access; public exploit available (1 source)"
|
||||
}
|
||||
```
|
||||
|
||||
**Why HIGH**: Path traversal + agent file access + public exploit elevates medium CVSS to high exploitability.
|
||||
|
||||
### Example 4: XSS in Agent UI (Medium Exploitability)
|
||||
|
||||
```json
|
||||
{
|
||||
"cve_id": "CVE-2024-45678",
|
||||
"cvss_score": 7.1,
|
||||
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L",
|
||||
"type": "cross_site_scripting",
|
||||
"description": "Stored XSS in agent management dashboard"
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis Output:**
|
||||
```json
|
||||
{
|
||||
"exploitability_score": "medium",
|
||||
"exploitability_rationale": "High CVSS score (7.1); network accessible; XSS has limited impact in headless agents"
|
||||
}
|
||||
```
|
||||
|
||||
**Why MEDIUM**: Despite high CVSS, XSS is less critical in agent deployments (headless operation). Requires user interaction.
|
||||
|
||||
### Example 5: Local Privilege Escalation (Medium Exploitability)
|
||||
|
||||
```json
|
||||
{
|
||||
"cve_id": "CVE-2024-56789",
|
||||
"cvss_score": 8.8,
|
||||
"cvss_vector": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
|
||||
"type": "privilege_escalation",
|
||||
"description": "Local privilege escalation via symbolic link attack"
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis Output:**
|
||||
```json
|
||||
{
|
||||
"exploitability_score": "medium",
|
||||
"exploitability_rationale": "High CVSS score (8.8); requires local access"
|
||||
}
|
||||
```
|
||||
|
||||
**Why MEDIUM**: Despite high CVSS, requires local access. Agents typically run in containerized/sandboxed environments where local escalation has limited impact.
|
||||
|
||||
### Example 6: Prototype Pollution with Exploit (High Exploitability)
|
||||
|
||||
```json
|
||||
{
|
||||
"cve_id": "CVE-2024-67890",
|
||||
"cvss_score": 5.3,
|
||||
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N",
|
||||
"type": "prototype_pollution",
|
||||
"description": "Prototype pollution in lodash merge function",
|
||||
"references": [
|
||||
"https://github.com/exploit/prototype-pollution-poc",
|
||||
"https://snyk.io/vuln/SNYK-JS-LODASH-1234567"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis Output (with --check-exploits):**
|
||||
```json
|
||||
{
|
||||
"exploitability_score": "high",
|
||||
"exploitability_rationale": "Medium CVSS score (5.3); remotely exploitable without authentication; prototype pollution can escalate in Node.js agents; public exploit available (1 source)"
|
||||
}
|
||||
```
|
||||
|
||||
**Why HIGH**: Prototype pollution in Node.js agents + public exploit + network-accessible without auth = high risk despite moderate CVSS.
|
||||
|
||||
## Usage in ClawSec Workflows
|
||||
|
||||
### Automated Scoring (NVD Feed)
|
||||
|
||||
The `poll-nvd-cves.yml` workflow automatically scores new CVEs:
|
||||
|
||||
```bash
|
||||
# Workflow step
|
||||
python utils/analyze_exploitability.py --json --check-exploits < cve-data.json
|
||||
```
|
||||
|
||||
Advisories in `advisories/feed.json` can include:
|
||||
```json
|
||||
{
|
||||
"id": "CVE-2024-12345",
|
||||
"severity": "high",
|
||||
"exploitability_score": "high",
|
||||
"exploitability_rationale": "Critical CVSS score (9.8); remotely exploitable without authentication; RCE is critical in agent deployments",
|
||||
"attack_vector_analysis": {
|
||||
"is_network_accessible": true,
|
||||
"requires_authentication": false,
|
||||
"requires_user_interaction": false,
|
||||
"complexity": "low"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Analysis
|
||||
|
||||
Security researchers can analyze CVEs manually:
|
||||
|
||||
```bash
|
||||
# Basic analysis
|
||||
echo '{"cve_id":"CVE-2024-12345","cvss_score":7.3,"type":"ssrf"}' | \
|
||||
python utils/analyze_exploitability.py --json
|
||||
|
||||
# With exploit detection
|
||||
echo '{"cve_id":"CVE-2024-12345","cvss_score":7.3,"references":["https://exploit-db.com/exploits/51234"]}' | \
|
||||
python utils/analyze_exploitability.py --json --check-exploits
|
||||
```
|
||||
|
||||
### Filtering by Exploitability
|
||||
|
||||
Users can filter advisories by exploitability score:
|
||||
|
||||
```bash
|
||||
# Get only high-exploitability advisories
|
||||
curl -s https://clawsec.prompt.security/feed.json | \
|
||||
jq '.advisories[] | select(.exploitability_score == "high")'
|
||||
|
||||
# Prioritize by exploitability and severity
|
||||
curl -s https://clawsec.prompt.security/feed.json | \
|
||||
jq '[.advisories[] | select(.exploitability_score == "high" and .severity == "critical")] | sort_by(.cvss_score) | reverse'
|
||||
```
|
||||
|
||||
## Backfilling Existing Advisories (Historical Maintenance)
|
||||
|
||||
`scripts/backfill-exploitability.sh` is retained as a historical maintainer utility for one-off repository maintenance.
|
||||
It is not the primary path for normal advisory generation.
|
||||
|
||||
Preferred paths:
|
||||
|
||||
1. CI canonical path: run the NVD workflow with init/reset to rebuild advisories from NVD and sign artifacts in pipeline.
|
||||
2. Local developer path: run `./scripts/populate-local-feed.sh --force` to repopulate local feeds with exploitability context.
|
||||
|
||||
Use backfill only when explicitly repairing legacy feed content that already exists in-repo.
|
||||
|
||||
## Community Contributions
|
||||
|
||||
Community members can submit exploitability assessments:
|
||||
|
||||
1. **Report via GitHub Issue**: Use the advisory template to report CVEs with exploitability context
|
||||
2. **Automated Analysis**: The `community-advisory.yml` workflow automatically scores community-reported CVEs
|
||||
3. **Manual Review**: Maintainers review and approve exploitability assessments
|
||||
4. **Feed Update**: Approved advisories are added to the feed with exploitability scores
|
||||
|
||||
## Limitations and Future Work
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **Static Analysis**: Scoring is based on CVE metadata, not dynamic runtime analysis
|
||||
2. **No Version Detection**: Doesn't check if specific versions are vulnerable
|
||||
3. **Binary Classification**: Doesn't consider partial mitigations or defense-in-depth
|
||||
4. **Limited Context**: Doesn't know exact agent configuration or deployed tools
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
1. **EPSS Integration**: Incorporate EPSS (Exploit Prediction Scoring System) probability scores
|
||||
2. **KEV Matching**: Cross-reference with CISA KEV (Known Exploited Vulnerabilities) catalog
|
||||
3. **Agent Profiling**: Consider deployed agent capabilities and exposed APIs
|
||||
4. **Mitigation Detection**: Check for WAF rules, sandboxing, or other compensating controls
|
||||
5. **ML-Based Scoring**: Use machine learning to predict exploitability based on historical data
|
||||
|
||||
## References
|
||||
|
||||
- **CVSS v3.1 Specification**: [https://www.first.org/cvss/v3.1/specification-document](https://www.first.org/cvss/v3.1/specification-document)
|
||||
- **CVSS v2 Guide**: [https://www.first.org/cvss/v2/guide](https://www.first.org/cvss/v2/guide)
|
||||
- **EPSS**: [https://www.first.org/epss/](https://www.first.org/epss/)
|
||||
- **CISA KEV**: [https://www.cisa.gov/known-exploited-vulnerabilities-catalog](https://www.cisa.gov/known-exploited-vulnerabilities-catalog)
|
||||
- **NVD API**: [https://nvd.nist.gov/developers/vulnerabilities](https://nvd.nist.gov/developers/vulnerabilities)
|
||||
|
||||
## Contributing
|
||||
|
||||
To improve the exploitability scoring methodology:
|
||||
|
||||
1. **Submit Test Cases**: Add test cases to `utils/analyze_exploitability.py`
|
||||
2. **Report False Positives/Negatives**: Open GitHub issues with CVE examples
|
||||
3. **Propose Scoring Adjustments**: Submit PRs with rationale and examples
|
||||
4. **Share Agent Context**: Contribute agent-specific vulnerability patterns
|
||||
|
||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) for detailed contribution guidelines.
|
||||
|
||||
---
|
||||
|
||||
**Maintained by**: [Prompt Security](https://prompt.security)
|
||||
**License**: AGPL-3.0-or-later
|
||||
**Last Updated**: 2026-03-01
|
||||
@@ -0,0 +1,57 @@
|
||||
# Glossary
|
||||
|
||||
## Terms
|
||||
| Term | Definition |
|
||||
| --- | --- |
|
||||
| Advisory Feed | JSON document (`feed.json`) containing security advisories for skills/platforms. |
|
||||
| Affected Specifier | Skill selector such as `skill@1.2.3`, wildcard, or range used in matching logic. |
|
||||
| Guarded Install | Two-step installer behavior that requires explicit confirmation when advisories match. |
|
||||
| SBOM Files | Skill-declared artifact list in `skill.json` used for packaging and validation. |
|
||||
| Detached Signature | Base64 signature file (`.sig`) stored separately from signed payload. |
|
||||
| Checksum Manifest | File hash map (`checksums.json`) used to verify payload integrity. |
|
||||
|
||||
## Skill Packaging Terms
|
||||
| Term | Definition |
|
||||
| --- | --- |
|
||||
| Skill Tag | Git tag formatted as `<skill>-v<semver>` used by release automation. |
|
||||
| Release Assets | Files attached to GitHub release (zip, `skill.json`, checksums, signatures). |
|
||||
| Catalog Index | `public/skills/index.json`, generated list consumed by web catalog. |
|
||||
| Embedded Components | Capability bundle from one skill included in another (for example feed embedded in suite). |
|
||||
|
||||
## Advisory and Security Terms
|
||||
| Term | Definition |
|
||||
| --- | --- |
|
||||
| Fail-Closed Verification | Reject payload if signature or checksum validation fails. |
|
||||
| Unsigned Compatibility Mode | Temporary bypass path enabled via `CLAWSEC_ALLOW_UNSIGNED_FEED=1`. |
|
||||
| Suppression Rule | Config entry matching `checkId` and `skill` to suppress known/accepted findings. |
|
||||
| Key Fingerprint | SHA-256 digest of DER-encoded public key used for key consistency checks. |
|
||||
|
||||
## Runtime and Platform Terms
|
||||
| Term | Definition |
|
||||
| --- | --- |
|
||||
| OpenClaw Hook | Runtime event handler (`clawsec-advisory-guardian`) that checks advisories. |
|
||||
| NanoClaw IPC | Host/container task exchange for advisory refresh, signature verification, integrity checks. |
|
||||
| Integrity Baseline | Stored approved hashes/snapshots for protected files. |
|
||||
| Hash-Chained Audit Log | Append-only audit log where each entry depends on prior hash. |
|
||||
|
||||
## CI/CD Terms
|
||||
| Term | Definition |
|
||||
| --- | --- |
|
||||
| Poll NVD CVEs Workflow | Scheduled workflow that fetches and transforms NVD CVEs into advisories. |
|
||||
| Community Advisory Workflow | Issue-label-triggered workflow that publishes approved community advisories. |
|
||||
| Skill Release Workflow | Tag-triggered packaging/signing/publishing pipeline for skills. |
|
||||
| Deploy Pages Workflow | Workflow that builds site assets and mirrors release/advisory artifacts. |
|
||||
|
||||
## Source References
|
||||
- types.ts
|
||||
- skills/clawsec-suite/skill.json
|
||||
- skills/clawsec-nanoclaw/skill.json
|
||||
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/suppression.mjs
|
||||
- skills/clawsec-nanoclaw/guardian/integrity-monitor.ts
|
||||
- scripts/populate-local-feed.sh
|
||||
- .github/workflows/poll-nvd-cves.yml
|
||||
- .github/workflows/community-advisory.yml
|
||||
- .github/workflows/skill-release.yml
|
||||
- .github/workflows/deploy-pages.yml
|
||||
@@ -1,26 +1,30 @@
|
||||
# Migration Plan: Unsigned Feed → Signed Feed
|
||||
# Migration Record: Unsigned Feed → Signed Feed (Completed)
|
||||
|
||||
## 1) Objective
|
||||
## 1) Objective and Status
|
||||
|
||||
Move ClawSec advisory distribution from unsigned `feed.json` delivery to detached-signature verification with minimal disruption.
|
||||
Document how ClawSec advisory distribution moved from unsigned `feed.json` delivery to detached-signature verification, with compatibility preserved for legacy clients.
|
||||
|
||||
This plan is written against the current repository behavior:
|
||||
- feed is produced by `poll-nvd-cves.yml` and `community-advisory.yml`
|
||||
- feed is published by `deploy-pages.yml`
|
||||
- suite consumers currently load unsigned JSON from remote/local fallback paths
|
||||
Current status on `main`:
|
||||
- Signed feed publishing is active in advisory workflows and deploy workflow.
|
||||
- Suite and NanoClaw consumers default to signed feed endpoints.
|
||||
- Unsigned behavior exists only as explicit compatibility bypass (`CLAWSEC_ALLOW_UNSIGNED_FEED=1`).
|
||||
|
||||
## 2) Baseline (today)
|
||||
## 2) Baseline (today, post-migration)
|
||||
|
||||
Current feed paths in active use:
|
||||
- Source of truth: `advisories/feed.json`
|
||||
- Source signature: `advisories/feed.json.sig`
|
||||
- Skill copy: `skills/clawsec-feed/advisories/feed.json`
|
||||
- Skill copy signature: `skills/clawsec-feed/advisories/feed.json.sig`
|
||||
- Pages copy: `public/advisories/feed.json`
|
||||
- Latest mirror copy: `public/releases/latest/download/advisories/feed.json`
|
||||
- Pages signature: `public/advisories/feed.json.sig`
|
||||
- Latest mirror copy: `public/releases/latest/download/advisories/feed.json` (+ `.sig`)
|
||||
|
||||
Current consumer defaults:
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||
- default URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
- `skills/clawsec-nanoclaw/lib/advisories.ts`
|
||||
- default URL: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
|
||||
## 3) Migration principles
|
||||
|
||||
@@ -29,21 +33,21 @@ Current consumer defaults:
|
||||
- **Measured rollout**: enforce verification after telemetry confirms stable signed publishing.
|
||||
- **Fast rollback**: preserve a path back to unsigned behavior while root cause is investigated.
|
||||
|
||||
## 4) Phased timeline
|
||||
## 4) Phased timeline (historical)
|
||||
|
||||
### Phase 0 — Preparation (Week 0)
|
||||
### Phase 0 — Preparation (Completed)
|
||||
|
||||
Deliverables:
|
||||
- signing keys generated and fingerprints recorded
|
||||
- GitHub secrets created
|
||||
- public key(s) added in repo
|
||||
- runbooks approved (`SECURITY-SIGNING.md`, this file)
|
||||
- runbooks approved (`security-signing-runbook.md`, this file)
|
||||
|
||||
Exit criteria:
|
||||
- key fingerprints verified by reviewer
|
||||
- protected branch/workflow controls enabled
|
||||
|
||||
### Phase 1 — CI signing enabled, no client enforcement (Week 1)
|
||||
### Phase 1 — CI signing enabled, no client enforcement (Completed)
|
||||
|
||||
Implement:
|
||||
- add feed signing step/workflow to produce `advisories/feed.json.sig`
|
||||
@@ -58,7 +62,7 @@ Exit criteria:
|
||||
- signatures generated successfully for all feed update paths
|
||||
- deploy artifacts contain both payload and signature companions
|
||||
|
||||
### Phase 2 — Consumer dual-read/dual-verify support (Week 2)
|
||||
### Phase 2 — Consumer dual-read/dual-verify support (Completed)
|
||||
|
||||
Implement in consumers:
|
||||
- read `feed.json` and `feed.json.sig`
|
||||
@@ -74,7 +78,7 @@ Exit criteria:
|
||||
- verification logic released and tested
|
||||
- no false-positive verification failures in soak period
|
||||
|
||||
### Phase 3 — Enforcement (Week 3)
|
||||
### Phase 3 — Enforcement (Completed)
|
||||
|
||||
Actions:
|
||||
- disable temporary unsigned fallback behavior in default paths
|
||||
@@ -85,7 +89,7 @@ Exit criteria:
|
||||
- all production clients verify signatures by default
|
||||
- no unsigned feed dependency in standard installation flow
|
||||
|
||||
### Phase 4 — Stabilization (Week 4)
|
||||
### Phase 4 — Stabilization (Ongoing)
|
||||
|
||||
Actions:
|
||||
- run first key rotation tabletop drill
|
||||
@@ -165,3 +169,12 @@ Go only if all are true:
|
||||
- consumer verification path tested for remote + local fallback
|
||||
- rollback owner is assigned and reachable
|
||||
- key rotation procedure has been dry-run at least once
|
||||
|
||||
## Source References
|
||||
- .github/workflows/poll-nvd-cves.yml
|
||||
- .github/workflows/community-advisory.yml
|
||||
- .github/workflows/deploy-pages.yml
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts
|
||||
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
|
||||
- advisories/feed.json
|
||||
- wiki/security-signing-runbook.md
|
||||
@@ -0,0 +1,98 @@
|
||||
# Module: Automation and Release Pipelines
|
||||
|
||||
## Responsibilities
|
||||
- Enforce repository quality/security checks before merge and deployment.
|
||||
- Generate and maintain advisory feed updates from automated and community sources.
|
||||
- Package, sign, and publish skill release artifacts from tag events.
|
||||
- Build and deploy static website outputs and mirrored release/advisory assets.
|
||||
|
||||
## Key Files
|
||||
- `.github/workflows/ci.yml`: lint/type/build/security/test matrix.
|
||||
- `.github/workflows/pages-verify.yml`: PR-only Pages build/signing verification (no publish).
|
||||
- `.github/workflows/poll-nvd-cves.yml`: daily NVD advisory ingestion.
|
||||
- `.github/workflows/community-advisory.yml`: issue-label-driven advisory publishing.
|
||||
- `.github/workflows/skill-release.yml`: release validation, packaging, signing, and publishing.
|
||||
- `.github/workflows/deploy-pages.yml`: site build + asset mirroring to GitHub Pages.
|
||||
- `.github/workflows/wiki-sync.yml`: syncs repository `wiki/` into GitHub Wiki.
|
||||
- `.github/actions/sign-and-verify/action.yml`: shared Ed25519 sign/verify composite action.
|
||||
- `scripts/prepare-to-push.sh`: local CI-like quality gate.
|
||||
- `scripts/release-skill.sh`: manual helper for version bump + tag workflow.
|
||||
|
||||
## Public Interfaces
|
||||
| Interface | Trigger | Outcome |
|
||||
| --- | --- | --- |
|
||||
| CI workflow | Push/PR on `main` | Fails fast on lint/type/build/test/security regressions. |
|
||||
| Pages Verify workflow | PR on `main` | Validates Pages build/signing artifacts without production deploy. |
|
||||
| NVD poll workflow | Cron + dispatch | Updates advisory feed with deduped, normalized CVEs. |
|
||||
| Community advisory workflow | Issue labeled `advisory-approved` | Opens PR adding signed advisory records. |
|
||||
| Skill release workflow | Metadata PR changes + tag `<skill>-v*` | PR dry-run/version checks and tagged release publishing. |
|
||||
| Deploy pages workflow | Successful CI/release run | Publishes site + mirrored artifacts to Pages. |
|
||||
| Sync wiki workflow | Push `wiki/**` on `main` | Publishes repository wiki content into GitHub Wiki remote. |
|
||||
|
||||
## Inputs and Outputs
|
||||
Inputs/outputs are summarized in the table below.
|
||||
|
||||
| Type | Name | Location | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| Input | Git refs/events | GitHub Actions event payloads | Determines which workflow path runs. |
|
||||
| Input | Skill metadata/SBOM | `skills/*/skill.json` | Drives release asset assembly and validation. |
|
||||
| Input | NVD API data | External API responses | Source CVEs for advisory feed generation. |
|
||||
| Input | Signing secrets | GitHub Secrets | Private key material for signing artifacts. |
|
||||
| Output | Signed advisories | `advisories/feed.json(.sig)` + mirrored public files | Consumable signed feed channel. |
|
||||
| Output | Skill release assets | `release-assets/*` and GitHub release attachments | Installable and verifiable skill artifacts. |
|
||||
| Output | Website build | `dist/` deployment artifact | Public web frontend and mirrors. |
|
||||
|
||||
## Configuration
|
||||
| Config Point | Location | Notes |
|
||||
| --- | --- | --- |
|
||||
| Workflow schedules | `poll-nvd-cves.yml`, `codeql.yml`, `scorecard.yml` | Daily/weekly security automation cadence. |
|
||||
| Concurrency groups | Workflow `concurrency` blocks | Prevents destructive overlap in key pipelines. |
|
||||
| Signing key checks | `scripts/ci/verify_signing_key_consistency.sh` | Ensures docs and canonical PEM files align. |
|
||||
| Local pre-push gating | `scripts/prepare-to-push.sh` | Mirrors CI checks with optional auto-fix. |
|
||||
|
||||
## Example Snippets
|
||||
```yaml
|
||||
# skill release trigger pattern
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*-v[0-9]*.[0-9]*.[0-9]*'
|
||||
```
|
||||
|
||||
```bash
|
||||
# local all-in-one pre-push gate
|
||||
./scripts/prepare-to-push.sh
|
||||
# optional auto-fix
|
||||
./scripts/prepare-to-push.sh --fix
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
- NVD API rate limiting (`403`/`429`) is handled with retry/backoff and can fail workflow on persistent errors.
|
||||
- Release pipeline blocks on version mismatch between `skill.json` and `SKILL.md` frontmatter.
|
||||
- Key fingerprint drift between canonical PEM files and docs hard-fails signing-related workflows.
|
||||
- Deploy workflow intentionally allows unsigned legacy checksums for backward compatibility in some branches.
|
||||
- Manual helper script has safety checks but includes destructive rollback logic in error branches; use carefully.
|
||||
|
||||
## Tests
|
||||
| Validation Layer | Location |
|
||||
| --- | --- |
|
||||
| Workflow execution tests | CI jobs in `.github/workflows/ci.yml` |
|
||||
| Skill-level unit/property tests | `skills/*/test/*.test.mjs` invoked by CI |
|
||||
| Local deterministic checks | `scripts/prepare-to-push.sh` |
|
||||
| Release link checks | `scripts/validate-release-links.sh` |
|
||||
|
||||
## Source References
|
||||
- .github/workflows/ci.yml
|
||||
- .github/workflows/poll-nvd-cves.yml
|
||||
- .github/workflows/community-advisory.yml
|
||||
- .github/workflows/skill-release.yml
|
||||
- .github/workflows/deploy-pages.yml
|
||||
- .github/workflows/pages-verify.yml
|
||||
- .github/workflows/wiki-sync.yml
|
||||
- .github/workflows/codeql.yml
|
||||
- .github/workflows/scorecard.yml
|
||||
- .github/actions/sign-and-verify/action.yml
|
||||
- scripts/prepare-to-push.sh
|
||||
- scripts/release-skill.sh
|
||||
- scripts/validate-release-links.sh
|
||||
- scripts/ci/verify_signing_key_consistency.sh
|
||||
@@ -0,0 +1,96 @@
|
||||
# Module: ClawSec Suite Core
|
||||
|
||||
## Responsibilities
|
||||
- Act as the main skill-of-skills security bundle for OpenClaw-style agents.
|
||||
- Verify advisory feed authenticity (Ed25519 signatures and optional checksum manifests).
|
||||
- Detect advisory matches against installed skills and emit actionable runtime alerts.
|
||||
- Enforce two-step confirmation for risky skill installations.
|
||||
|
||||
## Key Files
|
||||
- `skills/clawsec-suite/skill.json`: suite metadata, embedded components, catalog defaults.
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`: runtime event handler.
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs`: signed feed loading/parsing.
|
||||
- `skills/clawsec-suite/hooks/.../lib/matching.ts`: advisory-to-skill matching logic.
|
||||
- `skills/clawsec-suite/hooks/.../lib/state.ts`: scan state persistence.
|
||||
- `skills/clawsec-suite/hooks/.../lib/suppression.mjs`: allowlist-based suppression loader.
|
||||
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`: advisory-gated installer wrapper.
|
||||
- `skills/clawsec-suite/scripts/discover_skill_catalog.mjs`: remote/fallback catalog discovery.
|
||||
|
||||
## Public Interfaces
|
||||
| Interface | Consumer | Behavior |
|
||||
| --- | --- | --- |
|
||||
| Hook handler default export | OpenClaw hook runtime | Handles `agent:bootstrap` and `command:new` events. |
|
||||
| `guarded_skill_install.mjs` CLI | Operators/automation | Blocks on advisory matches unless `--confirm-advisory`. |
|
||||
| `discover_skill_catalog.mjs` CLI | Suite docs/automation | Lists installable skills with fallback metadata. |
|
||||
| `feed.mjs` functions | Suite scripts and tests | Feed load, signature verification, checksum manifest checks. |
|
||||
| Exit code contract | External wrappers | `42` indicates explicit second confirmation required. |
|
||||
|
||||
## Inputs and Outputs
|
||||
Inputs/outputs are summarized in the table below.
|
||||
|
||||
| Type | Name | Location | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| Input | Advisory feed + signatures | Remote URLs or local `advisories/` files | Risk intelligence source for hook and installer. |
|
||||
| Input | Installed skill metadata | Skill directories under install root | Matcher compares installed versions to advisory affected specs. |
|
||||
| Input | Suppression config | `OPENCLAW_AUDIT_CONFIG` or default config paths | Selective suppression by check and skill name. |
|
||||
| Output | Runtime alert messages | Hook event `messages[]` | Advisories and recommended user actions. |
|
||||
| Output | Persistent state | `~/.openclaw/clawsec-suite-feed-state.json` | De-dup alerts, track scan windows. |
|
||||
| Output | CLI gating exit codes | Installer process status | Ensures deliberate user confirmation on risk. |
|
||||
|
||||
## Configuration
|
||||
| Variable | Default | Module Effect |
|
||||
| --- | --- | --- |
|
||||
| `CLAWSEC_FEED_URL` | Hosted advisory URL | Chooses primary remote feed endpoint. |
|
||||
| `CLAWSEC_LOCAL_FEED*` vars | Suite-local advisories directory | Configures local signed fallback artifacts. |
|
||||
| `CLAWSEC_FEED_PUBLIC_KEY` | `advisories/feed-signing-public.pem` | Verification key path. |
|
||||
| `CLAWSEC_ALLOW_UNSIGNED_FEED` | `0` | Enables temporary migration bypass mode. |
|
||||
| `CLAWSEC_VERIFY_CHECKSUM_MANIFEST` | `1` | Enables checksum manifest verification layer. |
|
||||
| `CLAWSEC_HOOK_INTERVAL_SECONDS` | `300` | Controls event-driven scan throttling. |
|
||||
|
||||
## Example Snippets
|
||||
```ts
|
||||
// hook only handles selected events
|
||||
function shouldHandleEvent(event: HookEvent): boolean {
|
||||
const eventName = toEventName(event);
|
||||
return eventName === 'agent:bootstrap' || eventName === 'command:new';
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// guarded installer confirmation contract
|
||||
if (matches.length > 0 && !args.confirmAdvisory) {
|
||||
process.stdout.write('Re-run with --confirm-advisory to proceed.\n');
|
||||
process.exit(EXIT_CONFIRM_REQUIRED); // 42
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
- Missing/malformed feed signatures force remote rejection and local fallback attempts.
|
||||
- Ambiguous checksum manifest basename collisions are treated as errors.
|
||||
- Unknown skill versions are treated conservatively in version matching logic.
|
||||
- Suppression is disabled unless config includes the pipeline sentinel (`enabledFor`).
|
||||
- Invalid environment path tokens are rejected to avoid accidental literal path usage.
|
||||
|
||||
## Tests
|
||||
| Test File | Focus |
|
||||
| --- | --- |
|
||||
| `skills/clawsec-suite/test/feed_verification.test.mjs` | Signature/checksum verification and fail-closed behavior. |
|
||||
| `skills/clawsec-suite/test/guarded_install.test.mjs` | Confirmation gating and match semantics. |
|
||||
| `skills/clawsec-suite/test/path_resolution.test.mjs` | Home/path expansion and invalid token handling. |
|
||||
| `skills/clawsec-suite/test/advisory_suppression.test.mjs` | Suppression config parsing and matching. |
|
||||
| `skills/clawsec-suite/test/skill_catalog_discovery.test.mjs` | Remote index and fallback merge behavior. |
|
||||
|
||||
## Source References
|
||||
- skills/clawsec-suite/skill.json
|
||||
- skills/clawsec-suite/SKILL.md
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/matching.ts
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/state.ts
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/suppression.mjs
|
||||
- skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/version.mjs
|
||||
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
|
||||
- skills/clawsec-suite/scripts/discover_skill_catalog.mjs
|
||||
- skills/clawsec-suite/test/feed_verification.test.mjs
|
||||
- skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
- skills/clawsec-suite/test/path_resolution.test.mjs
|
||||
@@ -0,0 +1,107 @@
|
||||
# Module: Frontend Web App
|
||||
|
||||
## Responsibilities
|
||||
- Render the ClawSec website for home, skills catalog/detail, and advisory feed/detail experiences.
|
||||
- Render repository wiki content from `wiki/` markdown and expose per-page `llms.txt` links.
|
||||
- Provide resilient JSON fetch behavior that handles SPA HTML fallback cases.
|
||||
- Display install commands, checksums, and advisory metadata in a browser-focused UX.
|
||||
|
||||
## Key Files
|
||||
- `index.tsx`: React bootstrap and root mount.
|
||||
- `App.tsx`: Router map and page entry wiring.
|
||||
- `pages/Home.tsx`: Landing page, install card, animated platform/file labels.
|
||||
- `pages/SkillsCatalog.tsx`: Catalog fetch/filter state machine and empty-state handling.
|
||||
- `pages/SkillDetail.tsx`: Loads `skill.json`, checksums, README/SKILL docs with markdown renderer.
|
||||
- `pages/FeedSetup.tsx`: Advisory listing UI with pagination.
|
||||
- `pages/AdvisoryDetail.tsx`: Advisory deep-dive view and source links.
|
||||
- `pages/WikiBrowser.tsx`: In-app wiki renderer with wiki-page and `llms.txt` links.
|
||||
- `components/Layout.tsx` + `components/Header.tsx`: Shared shell and nav behavior.
|
||||
|
||||
## Public Interfaces
|
||||
- Browser routes:
|
||||
- `/`
|
||||
- `/skills`
|
||||
- `/skills/:skillId`
|
||||
- `/feed`
|
||||
- `/feed/:advisoryId`
|
||||
- `/wiki/*`
|
||||
- Static fetch targets:
|
||||
- `/skills/index.json`
|
||||
- `/skills/<skill>/skill.json`
|
||||
- `/skills/<skill>/checksums.json`
|
||||
- `/advisories/feed.json`
|
||||
- `/wiki/llms.txt`
|
||||
- `/wiki/<page>/llms.txt`
|
||||
- Display contracts:
|
||||
- `SkillMetadata`, `SkillJson`, `SkillChecksums`, `AdvisoryFeed`, `Advisory` from `types.ts`.
|
||||
|
||||
## Inputs and Outputs
|
||||
Inputs/outputs are summarized in the table below.
|
||||
|
||||
| Type | Name | Location | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| Input | Skills index JSON | `/skills/index.json` | List of published skills and metadata. |
|
||||
| Input | Skill payload files | `/skills/<id>/skill.json`, markdown docs, `checksums.json` | Detail-page content and integrity table. |
|
||||
| Input | Advisory feed JSON | `/advisories/feed.json`, then `https://clawsec.prompt.security/advisories/feed.json` (legacy mirror fallback to `/releases/latest/download/feed.json`) | Advisory list/detail content. |
|
||||
| Output | Route-specific UI states | Browser view state | Loading, empty, error, and populated experiences. |
|
||||
| Output | Copy-to-clipboard commands | Clipboard API | Install and checksum snippets copied for users. |
|
||||
|
||||
## Configuration
|
||||
- Build/runtime config comes from:
|
||||
- `vite.config.ts` (port, host, path alias)
|
||||
- `index.html` Tailwind config + custom fonts/colors
|
||||
- `constants.ts` (`ADVISORY_FEED_URL`, `LOCAL_FEED_PATH`)
|
||||
- Wiki markdown source lives in `wiki/`; `scripts/generate-wiki-llms.mjs` generates `public/wiki/**/llms.txt` (via `predev`/`prebuild`).
|
||||
- Runtime behavior assumptions:
|
||||
- JSON responses may be empty or HTML fallback and must be validated.
|
||||
- Advisory list pagination uses `ITEMS_PER_PAGE = 9`.
|
||||
|
||||
## Example Snippets
|
||||
```tsx
|
||||
// Catalog fetch logic guards against HTML fallback responses
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const raw = await response.text();
|
||||
if (!raw.trim() || contentType.includes('text/html') || isProbablyHtmlDocument(raw)) {
|
||||
setSkills([]);
|
||||
setFilteredSkills([]);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Route map defined in App.tsx
|
||||
<Route path="/skills/:skillId" element={<SkillDetail />} />
|
||||
<Route path="/feed/:advisoryId" element={<AdvisoryDetail />} />
|
||||
<Route path="/wiki/*" element={<WikiBrowser />} />
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
- Missing `skills/index.json` returns empty catalog instead of hard failure.
|
||||
- Some environments return `index.html` for missing JSON paths with status `200`; code defends against this.
|
||||
- Skill detail tolerates missing/malformed checksums and missing markdown docs.
|
||||
- Advisory detail handles absent optional fields (`cvss_score`, `reporter`, `references`).
|
||||
|
||||
## Tests
|
||||
| Test Type | Location | Notes |
|
||||
| --- | --- | --- |
|
||||
| Type/lint/build checks | `scripts/prepare-to-push.sh` + CI | Frontend confidence comes from static checks and build success. |
|
||||
| App-wide CI gates | `.github/workflows/ci.yml` | Multi-OS TypeScript/ESLint/build checks. |
|
||||
| Manual smoke checks | `npm run dev` | Validate route rendering and fetch paths during development. |
|
||||
|
||||
## Source References
|
||||
- index.tsx
|
||||
- App.tsx
|
||||
- pages/Home.tsx
|
||||
- pages/SkillsCatalog.tsx
|
||||
- pages/SkillDetail.tsx
|
||||
- pages/FeedSetup.tsx
|
||||
- pages/AdvisoryDetail.tsx
|
||||
- pages/WikiBrowser.tsx
|
||||
- pages/Checksums.tsx
|
||||
- components/Layout.tsx
|
||||
- components/Header.tsx
|
||||
- constants.ts
|
||||
- types.ts
|
||||
- vite.config.ts
|
||||
- index.html
|
||||
- scripts/generate-wiki-llms.mjs
|
||||
@@ -0,0 +1,84 @@
|
||||
# Module: Local Validation and Packaging Tools
|
||||
|
||||
## Responsibilities
|
||||
- Validate skill directory metadata/schema and SBOM file presence before release.
|
||||
- Generate per-skill checksums manifests for local testing or packaging.
|
||||
- Provide local data bootstrap scripts that mirror CI behavior for advisories and skills.
|
||||
- Offer release-link and signing-key consistency checks for maintainers.
|
||||
|
||||
## Key Files
|
||||
- `utils/validate_skill.py`: schema and file existence checks for a skill directory.
|
||||
- `utils/package_skill.py`: checksum manifest generator with skill pre-validation.
|
||||
- `scripts/populate-local-skills.sh`: generates local catalog and checksums under `public/skills/`.
|
||||
- `scripts/populate-local-feed.sh`: pulls NVD data and updates feed copies.
|
||||
- `scripts/validate-release-links.sh`: verifies docs reference releasable assets.
|
||||
- `scripts/ci/verify_signing_key_consistency.sh`: verifies key fingerprints across docs/files.
|
||||
|
||||
## Public Interfaces
|
||||
| Tool | Interface | Primary Output |
|
||||
| --- | --- | --- |
|
||||
| `validate_skill.py` | `python utils/validate_skill.py <skill-dir>` | Exit code + validation summary with warnings/errors. |
|
||||
| `package_skill.py` | `python utils/package_skill.py <skill-dir> [out-dir]` | `checksums.json` artifact for skill files. |
|
||||
| `populate-local-skills.sh` | shell CLI | `public/skills/index.json` and per-skill files/checksums. |
|
||||
| `populate-local-feed.sh` | shell CLI flags `--days`, `--force` | Updated advisory feeds in repo/skill/public paths. |
|
||||
| `validate-release-links.sh` | shell CLI optional skill arg | Release-link validation report. |
|
||||
|
||||
## Inputs and Outputs
|
||||
Inputs/outputs are summarized in the table below.
|
||||
|
||||
| Type | Name | Location | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| Input | Skill metadata and SBOM | `skills/<name>/skill.json` | Enumerates required files and release artifacts. |
|
||||
| Input | Existing feed state | `advisories/feed.json` | Determines incremental NVD polling start date. |
|
||||
| Input | Environment tools | `jq`, `curl`, `openssl`, Python runtime | Required execution dependencies. |
|
||||
| Output | Validation diagnostics | stdout/stderr + exit code | Signals readiness for release/CI. |
|
||||
| Output | Checksums manifests | `checksums.json` | Integrity data for skill artifacts. |
|
||||
| Output | Local mirrors | `public/skills/*`, `public/advisories/feed.json` | Makes local web preview match CI outputs. |
|
||||
|
||||
## Configuration
|
||||
| Setting | Location | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Ruff/Bandit policy | `pyproject.toml` | Python lint/security baseline. |
|
||||
| CLI flags (`--days`, `--force`) | `populate-local-feed.sh` | Controls window and overwrite semantics. |
|
||||
| `OPENCLAW_AUDIT_CONFIG` | suppression loaders in scripts | Chooses suppression config path. |
|
||||
| `CLAWSEC_*` env vars | installer/hook scripts | Path and verification behavior tuning. |
|
||||
|
||||
## Example Snippets
|
||||
```bash
|
||||
# validate and package a skill locally
|
||||
python utils/validate_skill.py skills/clawsec-feed
|
||||
python utils/package_skill.py skills/clawsec-feed ./dist
|
||||
```
|
||||
|
||||
```bash
|
||||
# refresh local UI data to mirror CI-generated artifacts
|
||||
./scripts/populate-local-skills.sh
|
||||
./scripts/populate-local-feed.sh --days 120
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
- Validation allows warnings (for example missing optional files) while still returning success when required fields/files are present.
|
||||
- NVD poll script handles macOS/Linux differences in `date` and `stat` utilities.
|
||||
- Release-link validation can detect doc references to files missing from SBOM-derived release assets.
|
||||
- Path expansion guards reject unexpanded home-token literals to avoid misdirected filesystem writes.
|
||||
|
||||
## Tests
|
||||
| Test/Check | Scope |
|
||||
| --- | --- |
|
||||
| `ruff check utils/` | Python style and correctness checks. |
|
||||
| `bandit -r utils/ -ll` | Python security issue scan. |
|
||||
| `scripts/prepare-to-push.sh` | Combined local gate across TS/Python/shell/security checks. |
|
||||
| Skill-local tests | `skills/*/test/*.test.mjs` (targeted invocation) |
|
||||
|
||||
## Source References
|
||||
- utils/validate_skill.py
|
||||
- utils/package_skill.py
|
||||
- pyproject.toml
|
||||
- scripts/populate-local-skills.sh
|
||||
- scripts/populate-local-feed.sh
|
||||
- scripts/prepare-to-push.sh
|
||||
- scripts/validate-release-links.sh
|
||||
- scripts/ci/verify_signing_key_consistency.sh
|
||||
- skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs
|
||||
- skills/clawsec-suite/scripts/guarded_skill_install.mjs
|
||||
- skills/clawsec-suite/scripts/discover_skill_catalog.mjs
|
||||
@@ -0,0 +1,97 @@
|
||||
# Module: NanoClaw Integration
|
||||
|
||||
## Responsibilities
|
||||
- Port ClawSec advisory/signature logic into NanoClaw host+container architecture.
|
||||
- Provide MCP tools that expose advisory checks, signature verification, and integrity monitoring.
|
||||
- Maintain host-side cached advisory state with TLS/signature enforcement and IPC-triggered refresh.
|
||||
- Protect critical NanoClaw files with baseline drift detection and hash-chained audit trails.
|
||||
|
||||
## Key Files
|
||||
- `skills/clawsec-nanoclaw/skill.json`: NanoClaw package contract and MCP tool registry.
|
||||
- `skills/clawsec-nanoclaw/lib/signatures.ts`: secure fetch and Ed25519 verification primitives.
|
||||
- `skills/clawsec-nanoclaw/lib/advisories.ts`: feed load and advisory matching helpers.
|
||||
- `skills/clawsec-nanoclaw/host-services/advisory-cache.ts`: host cache manager.
|
||||
- `skills/clawsec-nanoclaw/host-services/ipc-handlers.ts`: IPC request dispatch for advisory/signature tasks.
|
||||
- `skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts`: package signature verification service.
|
||||
- `skills/clawsec-nanoclaw/guardian/integrity-monitor.ts`: baseline/diff/restore/audit engine.
|
||||
- `skills/clawsec-nanoclaw/mcp-tools/*.ts`: container-side tool definitions.
|
||||
|
||||
## Public Interfaces
|
||||
| Interface | Context | Notes |
|
||||
| --- | --- | --- |
|
||||
| `clawsec_check_advisories` | MCP tool | Lists advisories affecting installed skills. |
|
||||
| `clawsec_check_skill_safety` | MCP tool | Returns install recommendation for a specific skill. |
|
||||
| `clawsec_verify_skill_package` | MCP tool | Verifies detached package signature through host IPC. |
|
||||
| `clawsec_check_integrity` | MCP tool | Runs integrity check, optional auto-restore for critical targets. |
|
||||
| IPC task `verify_skill_signature` | Host service | Returns structured verification response with error codes. |
|
||||
| IPC task `refresh_advisory_cache` | Host service | Refreshes signed advisory cache on demand. |
|
||||
|
||||
## Inputs and Outputs
|
||||
Inputs/outputs are summarized in the table below.
|
||||
|
||||
| Type | Name | Location | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| Input | Signed advisory feed | `https://clawsec.prompt.security/advisories/feed.json(.sig)` | Threat intelligence source for cache refresh. |
|
||||
| Input | Package + signature files | Host filesystem paths | Pre-install package authenticity checks. |
|
||||
| Input | Integrity policy | `guardian/policy.json` | Per-path mode and priority controls. |
|
||||
| Output | Advisory cache | `/workspace/project/data/clawsec-advisory-cache.json` | Host-managed verified advisory data. |
|
||||
| Output | Verification results | `/workspace/ipc/clawsec_results/*.json` | IPC response payload for tool calls. |
|
||||
| Output | Integrity state | `.../soul-guardian/` | Baselines, snapshots, patches, quarantine, audit logs. |
|
||||
|
||||
## Configuration
|
||||
| Setting | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| Feed URL | Hosted ClawSec advisory endpoint | Primary remote source for advisory cache manager. |
|
||||
| Cache TTL | `5 minutes` | Controls staleness threshold before requiring refresh. |
|
||||
| Fetch timeout | `10 seconds` | Limits host network wait time. |
|
||||
| Allowed domains | `clawsec.prompt.security`, `prompt.security`, `raw.githubusercontent.com`, `github.com` | Restricts remote fetch targets. |
|
||||
| Integrity policy modes | `restore`, `alert`, `ignore` | Controls automatic restoration and alert-only behavior. |
|
||||
|
||||
## Example Snippets
|
||||
```ts
|
||||
// host-side signature verification dispatch
|
||||
const result = await deps.signatureVerifier.verify({
|
||||
packagePath,
|
||||
signaturePath,
|
||||
publicKeyPem,
|
||||
allowUnsigned: allowUnsigned || false,
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// integrity monitor drift handling
|
||||
if (baseline.mode === 'restore' && autoRestore) {
|
||||
// quarantine modified file, restore approved snapshot, append audit event
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
- Disallowed domains or non-HTTPS URLs are blocked by security policy wrappers.
|
||||
- Missing signature files can be tolerated only when `allowUnsigned` is explicitly set.
|
||||
- IPC result waits can timeout, causing conservative block recommendations.
|
||||
- Integrity engine refuses symlink operations to reduce path-redirection attacks.
|
||||
- Audit-chain validation can detect tampering or corruption in historical records.
|
||||
|
||||
## Tests
|
||||
| Test Scope | File/Path | Notes |
|
||||
| --- | --- | --- |
|
||||
| Type contracts | `skills/clawsec-nanoclaw/lib/types.ts` | Defines tool/IPC DB payload contracts. |
|
||||
| Operational docs | `skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md`, `skills/clawsec-nanoclaw/docs/INTEGRITY.md` | Describes verification/integrity usage patterns. |
|
||||
| Cross-module behavior | Reuses suite verification patterns | Signature/checksum primitives ported from suite logic. |
|
||||
|
||||
## Source References
|
||||
- skills/clawsec-nanoclaw/skill.json
|
||||
- skills/clawsec-nanoclaw/lib/types.ts
|
||||
- skills/clawsec-nanoclaw/lib/signatures.ts
|
||||
- skills/clawsec-nanoclaw/lib/advisories.ts
|
||||
- skills/clawsec-nanoclaw/host-services/advisory-cache.ts
|
||||
- skills/clawsec-nanoclaw/host-services/ipc-handlers.ts
|
||||
- skills/clawsec-nanoclaw/host-services/skill-signature-handler.ts
|
||||
- skills/clawsec-nanoclaw/host-services/integrity-handler.ts
|
||||
- skills/clawsec-nanoclaw/guardian/integrity-monitor.ts
|
||||
- skills/clawsec-nanoclaw/guardian/policy.json
|
||||
- skills/clawsec-nanoclaw/mcp-tools/advisory-tools.ts
|
||||
- skills/clawsec-nanoclaw/mcp-tools/signature-verification.ts
|
||||
- skills/clawsec-nanoclaw/mcp-tools/integrity-tools.ts
|
||||
- skills/clawsec-nanoclaw/docs/SKILL_SIGNING.md
|
||||
- skills/clawsec-nanoclaw/docs/INTEGRITY.md
|
||||
@@ -0,0 +1,109 @@
|
||||
# Overview
|
||||
|
||||
## Purpose
|
||||
- ClawSec is a security-focused repository that combines a public web catalog with installable security skills for OpenClaw and NanoClaw environments.
|
||||
- The codebase supports three delivery paths at once: static website publishing, signed advisory distribution, and per-skill GitHub release packaging.
|
||||
- Primary users are agent operators, skill developers, and maintainers running CI-based security automation.
|
||||
|
||||

|
||||

|
||||
|
||||
## Repo Layout
|
||||
| Path | Role | Notes |
|
||||
| --- | --- | --- |
|
||||
| `pages/`, `components/`, `App.tsx`, `index.tsx` | Vite + React UI | Skill catalog, advisory feed, and detail pages. |
|
||||
| `skills/` | Security skill packages | Each skill has `skill.json`, `SKILL.md`, optional scripts/tests/docs. |
|
||||
| `advisories/` | Repository advisory channel | Signed `feed.json` + `feed.json.sig` and key material. |
|
||||
| `scripts/` | Local automation | Populate feed/skills, pre-push checks, release helpers. |
|
||||
| `.github/workflows/` | CI/CD pipelines | CI, releases, NVD polling, community advisory ingestion, pages deploy. |
|
||||
| `utils/` | Python utilities | Skill validation and checksum packaging helpers. |
|
||||
| `public/` | Published static assets | Site media, mirrored advisories, and generated skill artifacts. |
|
||||
| `wiki/` | Documentation hub | Architecture, operations runbooks, compatibility, and verification guides. |
|
||||
|
||||
## Entry Points
|
||||
| Entry | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `index.tsx` | Frontend bootstrap | Mounts React app into `#root`. |
|
||||
| `App.tsx` | Frontend router | Defines route map for home, skills, feed, and wiki pages. |
|
||||
| `scripts/prepare-to-push.sh` | Dev workflow | Runs lint/type/build/security checks before push. |
|
||||
| `scripts/populate-local-feed.sh` | Data bootstrap | Pulls CVEs from NVD and updates local advisory feeds. |
|
||||
| `scripts/populate-local-skills.sh` | Data bootstrap | Builds `public/skills/index.json` and per-skill checksums. |
|
||||
| `scripts/generate-wiki-llms.mjs` | Docs export | Generates `public/wiki/llms.txt` and per-page wiki exports. |
|
||||
| `.github/workflows/skill-release.yml` | Release entry | Handles PR version-parity/dry-run checks and tag-based packaging/signing/release. |
|
||||
| `.github/workflows/poll-nvd-cves.yml` | Scheduled feed updates | Polls NVD and updates advisories. |
|
||||
|
||||
## Key Artifacts
|
||||
| Artifact | Produced By | Consumed By |
|
||||
| --- | --- | --- |
|
||||
| `advisories/feed.json` | NVD poll + community advisory workflows | Web UI, clawsec-suite hook, installers. |
|
||||
| `advisories/feed.json.sig` | Signing workflow steps | Signature verification in suite/nanoclaw tooling. |
|
||||
| `public/skills/index.json` | Deploy workflow / local populate script | `pages/SkillsCatalog.tsx` and `pages/SkillDetail.tsx`. |
|
||||
| `public/wiki/llms.txt` + `public/wiki/**/llms.txt` | Wiki generator script + build hooks | LLM-ready wiki exports linked from the wiki UI. |
|
||||
| `public/checksums.json` + `public/checksums.sig` | Deploy workflow | Published integrity artifacts for operators and runtime clients. |
|
||||
| `release-assets/checksums.json` | Skill release workflow | Release consumers verifying zip integrity. |
|
||||
| `skills/*/skill.json` | Skill authors | Site catalog generation, validators, and release pipelines. |
|
||||
|
||||
## Key Workflows
|
||||
- Local web development: `npm install` then `npm run dev`.
|
||||
- Local security data preview: run `./scripts/populate-local-skills.sh` and `./scripts/populate-local-feed.sh` before loading `/skills` and `/feed` pages.
|
||||
- Pre-push quality gate: run `./scripts/prepare-to-push.sh` (optionally `--fix`).
|
||||
- Skill lifecycle: edit `skills/<name>/`, validate with `python utils/validate_skill.py`, then tag `<skill>-vX.Y.Z` to trigger release workflow.
|
||||
- Advisory lifecycle: scheduled NVD poll and issue-label-based community ingestion both merge into the same signed feed.
|
||||
|
||||
## Example Snippets
|
||||
```bash
|
||||
# local UI + locally populated data
|
||||
npm install
|
||||
./scripts/populate-local-skills.sh
|
||||
./scripts/populate-local-feed.sh --days 120
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
# canonical TypeScript quality checks used by CI
|
||||
npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0
|
||||
npx tsc --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Where to Start
|
||||
- Read `README.md` for product positioning and install paths.
|
||||
- Open `App.tsx` and `pages/` to understand user-facing behavior.
|
||||
- Open `skills/clawsec-suite/skill.json` to understand the suite contract and embedded components.
|
||||
- Review `.github/workflows/ci.yml`, `.github/workflows/pages-verify.yml`, `.github/workflows/skill-release.yml`, `.github/workflows/deploy-pages.yml`, and `.github/workflows/wiki-sync.yml` for production behavior.
|
||||
|
||||
## How to Navigate
|
||||
- UI behavior is centered in `pages/`; visual wrappers sit in `components/`.
|
||||
- Skill-specific logic is isolated by folder under `skills/`; each folder includes its own scripts/tests/docs.
|
||||
- Feed handling appears in three layers: repository feed files, workflow updates, and runtime consumers (`clawsec-suite`/`clawsec-nanoclaw`).
|
||||
- Operational quality gates live in `scripts/` and workflow YAML files.
|
||||
- For generation traces and update baselines, start from `wiki/GENERATION.md` and then branch into module pages.
|
||||
|
||||
## Common Pitfalls
|
||||
- Using literal home tokens (for example `\$HOME`) in config path env vars can trigger path validation failures.
|
||||
- Fetching JSON from SPA routes can return HTML with status 200; pages guard for this and treat it as empty-state.
|
||||
- Unsigned feed bypass mode (`CLAWSEC_ALLOW_UNSIGNED_FEED=1`) exists for migration compatibility and should not be used in steady state.
|
||||
- Skill release automation expects version parity between `skill.json` and `SKILL.md` frontmatter.
|
||||
- Some scripts are POSIX shell oriented; Windows users should prefer PowerShell equivalents or WSL.
|
||||
|
||||
## Update Notes
|
||||
- 2026-02-26: Updated repo layout to point operational documentation at `wiki/` instead of the removed root `docs/` directory.
|
||||
|
||||
## Source References
|
||||
- README.md
|
||||
- package.json
|
||||
- App.tsx
|
||||
- index.tsx
|
||||
- pages/Home.tsx
|
||||
- pages/SkillsCatalog.tsx
|
||||
- pages/SkillDetail.tsx
|
||||
- pages/FeedSetup.tsx
|
||||
- scripts/prepare-to-push.sh
|
||||
- scripts/populate-local-feed.sh
|
||||
- scripts/populate-local-skills.sh
|
||||
- skills/clawsec-suite/skill.json
|
||||
- .github/workflows/ci.yml
|
||||
- .github/workflows/pages-verify.yml
|
||||
- .github/workflows/skill-release.yml
|
||||
- .github/workflows/deploy-pages.yml
|
||||
- .github/workflows/wiki-sync.yml
|
||||