mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-16 15:01:22 +03:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d3fe1bf10 | |||
| f0f33b8121 | |||
| 9e79645536 | |||
| e47d1e2d69 | |||
| e6a1765a7f | |||
| 600c945fe2 | |||
| caad6f698c | |||
| 6c33384947 | |||
| a11314faa9 | |||
| 969a902fa6 | |||
| c72f366354 | |||
| 6c17509c80 | |||
| b28fd02841 | |||
| 0373a137ee | |||
| e2f4303fcc | |||
| 0cfb9b4784 | |||
| eeb1a5d632 | |||
| b39fe73e45 | |||
| 7cafbd7d77 | |||
| a7a0993029 | |||
| 9827f08769 | |||
| b996cff4bd | |||
| bd6e9e284a | |||
| e0083353cf | |||
| 01f651d6aa | |||
| bd17103892 | |||
| eedcb8b85c | |||
| 28bf775d47 | |||
| 30bcb96a23 | |||
| 0a320d18d4 | |||
| 989ea41198 | |||
| eb124b5f11 | |||
| 277c0abe17 | |||
| f0f0f1db97 | |||
| 687822b6cb | |||
| e715c8a625 | |||
| bd54393ed4 | |||
| 0fcc6e6b6d | |||
| 8d292457fb | |||
| 1cced651a0 | |||
| 83ce1d0bf5 | |||
| f9a7565d6f | |||
| 81c2e60513 | |||
| 19b53609c1 | |||
| 79c303fa3f | |||
| e0eae65586 | |||
| 56a36b7e52 | |||
| 8ad38dfdc6 | |||
| 3c336021d7 | |||
| 073e771b73 | |||
| 382db82483 | |||
| c9a66d5c99 | |||
| e4ca378603 | |||
| 5c5c7f539a | |||
| 7c0aa37a05 | |||
| 86342d2789 | |||
| 95c856ad8a | |||
| fefecaa60a | |||
| 8132c23f41 | |||
| 433a9596a6 | |||
| c17931d38d | |||
| 516e8f0428 | |||
| cbc484faf3 | |||
| 448aed3261 | |||
| 037bd125b9 | |||
| 5ef122dd91 | |||
| 938eb929f3 | |||
| 55fb234fc0 | |||
| ea44aea49e | |||
| 2e64201254 | |||
| 371d792e97 | |||
| 0602c0fbe5 | |||
| 8908319dd0 | |||
| 6f2fe918a2 | |||
| 7cdb4ab7e2 | |||
| 73dd63f714 | |||
| db0339084f | |||
| af0a515166 | |||
| 3142707dbd | |||
| c6409d2641 | |||
| e06c3952a3 | |||
| c61e4e5dbc | |||
| bd8931a094 | |||
| be5140aaae | |||
| 047b3ffa06 | |||
| 143dd311c6 | |||
| f43f792a88 | |||
| bfd230a178 | |||
| d5cf5c0b9c | |||
| 74a6d23a20 | |||
| 5e2f623ead | |||
| b05265fba1 | |||
| 176aa1f06a | |||
| 63de5ce08d | |||
| d41101a20c | |||
| 654dc5fbcf | |||
| 8b599f95dc | |||
| 8e744dfbb1 | |||
| c5c812adc8 | |||
| 65c40f67d9 | |||
| 398bd450ac | |||
| 51532bc753 | |||
| 76778b8bb6 | |||
| 26fa73fc92 | |||
| 8918171c6d | |||
| 705d38f39f | |||
| 5ee8587b1e | |||
| 331219eec3 | |||
| 0554a7ffd2 | |||
| 1ff41b6127 | |||
| 9e4134c63e | |||
| 2974daed6c | |||
| 6caef15234 | |||
| 1429ddd241 | |||
| 83ec542a1e | |||
| 3ffa6eed68 | |||
| 57eeb6d8f3 | |||
| 4542b7b96b | |||
| 85966ff569 | |||
| 24db3d46a4 | |||
| e08c91b504 | |||
| 57720d5493 | |||
| a706ef9df9 | |||
| 7f741d11da | |||
| e9db0c48c9 | |||
| e329a71de6 | |||
| ad8a751b77 | |||
| 186c2ec165 | |||
| 9ae2efa2f7 | |||
| 5ded815e6a | |||
| 87f80aae94 | |||
| c856bb6426 | |||
| 4783849476 | |||
| 4904990500 | |||
| c7749e6d5a | |||
| ecf715940d | |||
| 007a9cc5f4 | |||
| fae4444526 | |||
| db091fb8b3 | |||
| b950c7d937 | |||
| 96741196e5 | |||
| c31b81f24f | |||
| 8c4f7d594c | |||
| 973b0a0016 |
@@ -0,0 +1,24 @@
|
||||
* text=auto
|
||||
|
||||
# Keep executable/script sources LF across platforms.
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
*.zsh text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.js text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.py text eol=lf
|
||||
|
||||
# Keep config/docs deterministic in CI and local tooling.
|
||||
*.md text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.pem text eol=lf
|
||||
|
||||
# Binary assets.
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.ttf binary
|
||||
@@ -0,0 +1,110 @@
|
||||
name: Sign and Verify File
|
||||
description: Sign a file with the CI private key and verify the signature against one or more files.
|
||||
|
||||
inputs:
|
||||
private_key:
|
||||
description: PEM-encoded private key contents.
|
||||
required: true
|
||||
private_key_passphrase:
|
||||
description: Optional passphrase for encrypted private keys.
|
||||
required: false
|
||||
default: ""
|
||||
input_file:
|
||||
description: File to sign.
|
||||
required: true
|
||||
signature_file:
|
||||
description: Output path for base64 signature.
|
||||
required: true
|
||||
verify_files:
|
||||
description: Newline-separated list of files to verify with the generated signature. Defaults to input_file.
|
||||
required: false
|
||||
default: ""
|
||||
public_key_output:
|
||||
description: Optional output path for the derived public key.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
signature_file:
|
||||
description: Signature file path.
|
||||
value: ${{ steps.sign.outputs.signature_file }}
|
||||
public_key_file:
|
||||
description: Public key file path when public_key_output is set.
|
||||
value: ${{ steps.sign.outputs.public_key_file }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: sign
|
||||
shell: bash
|
||||
env:
|
||||
PRIVATE_KEY: ${{ inputs.private_key }}
|
||||
PRIVATE_KEY_PASSPHRASE: ${{ inputs.private_key_passphrase }}
|
||||
INPUT_FILE: ${{ inputs.input_file }}
|
||||
SIGNATURE_FILE: ${{ inputs.signature_file }}
|
||||
VERIFY_FILES_RAW: ${{ inputs.verify_files }}
|
||||
PUBLIC_KEY_OUTPUT: ${{ inputs.public_key_output }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${PRIVATE_KEY:-}" ]; then
|
||||
echo "::error::Missing required private key input."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${INPUT_FILE}" ]; then
|
||||
echo "::error file=${INPUT_FILE}::Input file not found for signing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
key_file="${tmp_dir}/private.pem"
|
||||
pub_file="${tmp_dir}/public.pem"
|
||||
sig_bin="${tmp_dir}/signature.bin"
|
||||
|
||||
printf '%s' "${PRIVATE_KEY}" > "${key_file}"
|
||||
|
||||
passin_args=()
|
||||
if [ -n "${PRIVATE_KEY_PASSPHRASE:-}" ]; then
|
||||
passin_args=(-passin "pass:${PRIVATE_KEY_PASSPHRASE}")
|
||||
fi
|
||||
|
||||
openssl pkey -in "${key_file}" "${passin_args[@]}" -pubout -out "${pub_file}"
|
||||
|
||||
mkdir -p "$(dirname "${SIGNATURE_FILE}")"
|
||||
# Sign with Ed25519 (requires -rawin flag for raw input)
|
||||
openssl pkeyutl -sign -rawin -inkey "${key_file}" "${passin_args[@]}" -in "${INPUT_FILE}" \
|
||||
| openssl base64 -A > "${SIGNATURE_FILE}"
|
||||
|
||||
openssl base64 -d -A -in "${SIGNATURE_FILE}" -out "${sig_bin}"
|
||||
|
||||
verify_files="${VERIFY_FILES_RAW}"
|
||||
if [ -z "${verify_files}" ]; then
|
||||
verify_files="${INPUT_FILE}"
|
||||
fi
|
||||
|
||||
while IFS= read -r verify_file; do
|
||||
[ -z "${verify_file}" ] && continue
|
||||
if [ ! -f "${verify_file}" ]; then
|
||||
echo "::error file=${verify_file}::Verification target does not exist."
|
||||
exit 1
|
||||
fi
|
||||
# Verify Ed25519 signature (requires -rawin flag for raw input)
|
||||
openssl pkeyutl -verify -rawin -pubin -inkey "${pub_file}" -sigfile "${sig_bin}" -in "${verify_file}" >/dev/null
|
||||
done <<< "${verify_files}"
|
||||
|
||||
if [ -n "${PUBLIC_KEY_OUTPUT}" ]; then
|
||||
mkdir -p "$(dirname "${PUBLIC_KEY_OUTPUT}")"
|
||||
cp "${pub_file}" "${PUBLIC_KEY_OUTPUT}"
|
||||
echo "public_key_file=${PUBLIC_KEY_OUTPUT}" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "public_key_file=" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
echo "signature_file=${SIGNATURE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||
@@ -0,0 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/.github"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
@@ -0,0 +1,2 @@
|
||||
ruff==0.15.9
|
||||
bandit==1.9.4
|
||||
+68
-19
@@ -3,16 +3,24 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
lint-typescript:
|
||||
name: Lint TypeScript/React
|
||||
runs-on: ubuntu-latest
|
||||
name: Lint TypeScript/React (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
@@ -22,30 +30,29 @@ jobs:
|
||||
- name: TypeScript Check
|
||||
run: npx tsc --noEmit
|
||||
- name: Build Check
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: npm run build
|
||||
|
||||
lint-python:
|
||||
name: Lint Python
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install linters
|
||||
run: pip install ruff bandit
|
||||
- name: Ruff (lint + format check)
|
||||
run: ruff check utils/ --output-format=github
|
||||
run: pipx run --spec "ruff==0.6.9" ruff check utils/ --output-format=github
|
||||
- name: Bandit (security)
|
||||
run: bandit -r utils/ -ll
|
||||
run: pipx run --spec "bandit==1.7.9" bandit -r utils/ -ll
|
||||
|
||||
lint-shell:
|
||||
name: Lint Shell Scripts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: ShellCheck
|
||||
uses: ludeeus/action-shellcheck@master
|
||||
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
|
||||
with:
|
||||
scandir: './scripts'
|
||||
severity: warning
|
||||
@@ -54,9 +61,9 @@ jobs:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Trivy FS Scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
@@ -64,7 +71,7 @@ jobs:
|
||||
exit-code: '1'
|
||||
ignore-unfixed: true
|
||||
- name: Trivy Config Scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
with:
|
||||
scan-type: 'config'
|
||||
scan-ref: '.'
|
||||
@@ -75,8 +82,8 @@ jobs:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
@@ -85,3 +92,45 @@ jobs:
|
||||
run: npm audit --audit-level=high --registry=https://registry.npmjs.org
|
||||
- name: Check for outdated deps
|
||||
run: npm outdated || true
|
||||
|
||||
clawsec-suite-tests:
|
||||
name: ClawSec Suite Verification Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: Feed Verification Tests
|
||||
run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||
- name: Guarded Install Tests
|
||||
run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
- name: Advisory Suppression Tests
|
||||
run: node skills/clawsec-suite/test/advisory_suppression.test.mjs
|
||||
- name: Path Resolution Tests
|
||||
run: node skills/clawsec-suite/test/path_resolution.test.mjs
|
||||
- name: Fuzz Property Tests
|
||||
run: node skills/clawsec-suite/test/fuzz_properties.test.mjs
|
||||
- name: Semver/Scope/Suppression Fuzz Tests
|
||||
run: node skills/clawsec-suite/test/fuzz_semver_scope_suppression.test.mjs
|
||||
- name: Advisory Application Scope Tests
|
||||
run: node skills/clawsec-suite/test/advisory_application_scope.test.mjs
|
||||
|
||||
openclaw-audit-watchdog-tests:
|
||||
name: OpenClaw Audit Watchdog Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: Suppression Config Tests
|
||||
run: node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
|
||||
- name: Suppression Config Fuzz Tests
|
||||
run: node skills/openclaw-audit-watchdog/test/suppression_config_fuzz.test.mjs
|
||||
- name: Render Report Suppression Tests
|
||||
run: node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (CodeQL)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["javascript-typescript"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4
|
||||
@@ -4,9 +4,7 @@ on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
permissions: read-all
|
||||
|
||||
concurrency:
|
||||
group: community-advisory
|
||||
@@ -14,7 +12,9 @@ concurrency:
|
||||
|
||||
env:
|
||||
FEED_PATH: advisories/feed.json
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
|
||||
jobs:
|
||||
process-advisory:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -113,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
|
||||
@@ -138,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" \
|
||||
@@ -151,6 +178,7 @@ jobs:
|
||||
title: $title,
|
||||
description: $description,
|
||||
affected: $affected,
|
||||
platforms: $platforms,
|
||||
action: $action,
|
||||
published: $published,
|
||||
references: [],
|
||||
@@ -165,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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.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: |
|
||||
@@ -196,47 +245,92 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit changes
|
||||
- name: Sign advisory feed and verify
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: ${{ env.FEED_PATH }}
|
||||
signature_file: ${{ env.FEED_SIG_PATH }}
|
||||
verify_files: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
|
||||
- name: Sync advisory signature to skill feed
|
||||
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: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
if [ -z "$AUTOMATION_TOKEN" ]; then
|
||||
echo "::error::Set POLL_NVD_CVES_PAT with repo write permissions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git add "$FEED_PATH" "$SKILL_FEED_PATH"
|
||||
- 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.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 }}"
|
||||
body: |
|
||||
## Summary
|
||||
Add community advisory `${{ steps.parse.outputs.advisory_id }}` from issue #${{ github.event.issue.number }}.
|
||||
|
||||
ADVISORY_ID="${{ steps.parse.outputs.advisory_id }}"
|
||||
git commit -m "chore: add community advisory $ADVISORY_ID
|
||||
- Issue: ${{ github.event.issue.html_url }}
|
||||
- Reporter: @${{ github.event.issue.user.login }}
|
||||
- Trigger: `advisory-approved` label
|
||||
|
||||
Added from issue #${{ github.event.issue.number }}
|
||||
Issue: ${{ github.event.issue.html_url }}"
|
||||
---
|
||||
*This PR was generated by the community advisory workflow.*
|
||||
commit-message: |
|
||||
chore: add community advisory ${{ steps.parse.outputs.advisory_id }}
|
||||
|
||||
git push
|
||||
Added from issue #${{ github.event.issue.number }}
|
||||
Issue: ${{ github.event.issue.html_url }}
|
||||
add-paths: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.FEED_SIG_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
${{ env.SKILL_FEED_SIG_PATH }}
|
||||
|
||||
- name: Comment on issue
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
uses: actions/github-script@v7
|
||||
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 }}';
|
||||
const operation = '${{ steps.create-pr.outputs.pull-request-operation }}';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `## Advisory Published
|
||||
body: `## Advisory Pull Request Opened
|
||||
|
||||
This security report has been published to the ClawSec advisory feed.
|
||||
This security report has been prepared for publication in the ClawSec advisory feed.
|
||||
|
||||
**Advisory ID:** \`${advisoryId}\`
|
||||
**Pull Request:** ${pullRequestUrl || 'No PR generated (no file changes detected)'}
|
||||
**PR Operation:** \`${operation || 'none'}\`
|
||||
|
||||
The advisory is now available in the feed and will be picked up by agents on their next feed check.
|
||||
The advisory will be published after the pull request is merged.
|
||||
|
||||
Thank you for your contribution to community security!`
|
||||
});
|
||||
|
||||
- name: Comment if already exists
|
||||
if: steps.parse.outputs.already_exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
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,9 +1,10 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI", "Skill Release"]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_run:
|
||||
workflows: ["Skill Release"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -19,14 +20,30 @@ 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@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify signing key consistency (repo + docs)
|
||||
run: ./scripts/ci/verify_signing_key_consistency.sh
|
||||
|
||||
- name: Auto-discover skills from releases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
@@ -48,17 +65,17 @@ jobs:
|
||||
}
|
||||
export -f download_asset # Export for use in subshells (while loop)
|
||||
|
||||
# Fetch all releases
|
||||
RELEASES=$(curl -sSL \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
# Fetch all releases (paginated)
|
||||
RELEASES=$(gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${REPO}/releases?per_page=100")
|
||||
"/repos/${REPO}/releases?per_page=100" \
|
||||
| jq -s 'add // []')
|
||||
|
||||
# Start building skills index
|
||||
echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json
|
||||
|
||||
FIRST_SKILL=true
|
||||
PROCESSED_SKILLS=""
|
||||
declare -A PROCESSED_SKILLS=()
|
||||
|
||||
# Process each release (using process substitution to avoid subshell)
|
||||
while read -r release; do
|
||||
@@ -70,7 +87,7 @@ jobs:
|
||||
VERSION="${BASH_REMATCH[2]}"
|
||||
|
||||
# Skip if we already processed a newer version of this skill
|
||||
if echo "$PROCESSED_SKILLS" | grep -q "^${SKILL_NAME}$"; then
|
||||
if [[ -n "${PROCESSED_SKILLS[$SKILL_NAME]+x}" ]]; then
|
||||
echo "Skipping older version: $TAG (already have newer)"
|
||||
continue
|
||||
fi
|
||||
@@ -99,13 +116,16 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Mirror all release assets under a GitHub-compatible path so users can
|
||||
# swap the host (github.com → clawsec.prompt.security) if GitHub is blocked.
|
||||
MIRROR_DIR="public/releases/download/${TAG}"
|
||||
mkdir -p "$MIRROR_DIR"
|
||||
mv "$SKILL_JSON_TMP" "$MIRROR_DIR/skill.json"
|
||||
# Security: Download to temp directory first, verify signatures, then mirror to final location.
|
||||
# This ensures unverified releases never appear in public/releases or the skills catalog.
|
||||
|
||||
# Download all remaining assets for this release (retain asset names)
|
||||
# Use temp directory for downloads before verification
|
||||
TEMP_DOWNLOAD_DIR=$(mktemp -d)
|
||||
|
||||
# Move skill.json to temp dir first
|
||||
mv "$SKILL_JSON_TMP" "$TEMP_DOWNLOAD_DIR/skill.json"
|
||||
|
||||
# Download all remaining assets to temp dir
|
||||
while read -r asset; do
|
||||
ASSET_ID=$(echo "$asset" | jq -r '.id')
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
@@ -121,16 +141,41 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
download_asset "$ASSET_ID" "$MIRROR_DIR/$ASSET_NAME"
|
||||
echo " Mirrored: $ASSET_NAME"
|
||||
download_asset "$ASSET_ID" "$TEMP_DOWNLOAD_DIR/$ASSET_NAME"
|
||||
echo " Downloaded to temp: $ASSET_NAME"
|
||||
done < <(echo "$release" | jq -c '.assets[]')
|
||||
|
||||
# Verify signed checksums when signature artifacts are present.
|
||||
# Legacy releases without signatures are still mirrored for backward compatibility.
|
||||
if [ -f "$TEMP_DOWNLOAD_DIR/checksums.sig" ] && [ -f "$TEMP_DOWNLOAD_DIR/signing-public.pem" ] && [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
|
||||
openssl base64 -d -A -in "$TEMP_DOWNLOAD_DIR/checksums.sig" -out "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
|
||||
# Verify Ed25519 signature (requires -rawin)
|
||||
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$TEMP_DOWNLOAD_DIR/signing-public.pem" -sigfile "$TEMP_DOWNLOAD_DIR/checksums.sig.bin" -in "$TEMP_DOWNLOAD_DIR/checksums.json"; then
|
||||
echo " Warning: Invalid checksums signature for $TAG; skipping skill"
|
||||
rm -rf "$TEMP_DOWNLOAD_DIR"
|
||||
continue
|
||||
fi
|
||||
rm -f "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
|
||||
echo " Verified checksums signature"
|
||||
elif [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
|
||||
echo " Warning: Unsigned legacy checksums for $TAG (missing checksums.sig/signing-public.pem)"
|
||||
fi
|
||||
|
||||
# Verification passed or skipped (legacy) - mirror to final location
|
||||
MIRROR_DIR="public/releases/download/${TAG}"
|
||||
mkdir -p "$MIRROR_DIR"
|
||||
cp -r "$TEMP_DOWNLOAD_DIR"/* "$MIRROR_DIR"/
|
||||
echo " Mirrored to: $MIRROR_DIR"
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$TEMP_DOWNLOAD_DIR"
|
||||
|
||||
# Copy the subset needed for the site catalog (skill pages)
|
||||
mkdir -p "public/skills/${SKILL_NAME}"
|
||||
cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json"
|
||||
echo " Added to catalog: skill.json"
|
||||
|
||||
for file in checksums.json README.md SKILL.md; do
|
||||
for file in checksums.json checksums.sig signing-public.pem README.md SKILL.md; do
|
||||
if [ -f "$MIRROR_DIR/$file" ]; then
|
||||
cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file"
|
||||
echo " Added to catalog: $file"
|
||||
@@ -158,7 +203,7 @@ jobs:
|
||||
echo "$SKILL_DATA" >> public/skills/index.json
|
||||
|
||||
# Mark this skill as processed (track newest only)
|
||||
PROCESSED_SKILLS="${PROCESSED_SKILLS}${SKILL_NAME}\n"
|
||||
PROCESSED_SKILLS["$SKILL_NAME"]=1
|
||||
else
|
||||
echo " Warning: skill.json not found in release assets"
|
||||
fi
|
||||
@@ -179,35 +224,117 @@ jobs:
|
||||
echo "=== Skills Directory ==="
|
||||
ls -la public/skills/
|
||||
|
||||
- name: Create root checksums placeholder
|
||||
run: |
|
||||
# Create empty checksums.json placeholder for root level
|
||||
echo '{"version":"1.0.0","files":{}}' > public/checksums.json
|
||||
echo "Created checksums.json placeholder"
|
||||
|
||||
- name: Copy advisory feed to public
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p public/advisories
|
||||
cp advisories/feed.json public/advisories/feed.json
|
||||
echo "Copied advisory feed to public/advisories/"
|
||||
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
|
||||
|
||||
- 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")
|
||||
|
||||
# Generate checksums manifest conforming to parseChecksumsManifest expectations:
|
||||
# - schema_version: "1" (manifest format version)
|
||||
# - algorithm: "sha256" (hash algorithm)
|
||||
# - version: "1.1.0" (feed content version, for informational purposes)
|
||||
# - generated_at, repository: metadata
|
||||
# - files: map of path -> {sha256, size, path, url}
|
||||
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
|
||||
|
||||
echo "Generated public/checksums.json"
|
||||
jq . public/checksums.json
|
||||
|
||||
- name: Sign advisory feed and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
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: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: public/checksums.json
|
||||
signature_file: public/checksums.sig
|
||||
|
||||
- name: Verify generated public signing key matches canonical key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CANONICAL_FPR=$(openssl pkey -pubin -in clawsec-signing-public.pem -outform DER | sha256sum | awk '{print $1}')
|
||||
GENERATED_FPR=$(openssl pkey -pubin -in public/signing-public.pem -outform DER | sha256sum | awk '{print $1}')
|
||||
echo "Canonical key fingerprint: $CANONICAL_FPR"
|
||||
echo "Generated key fingerprint: $GENERATED_FPR"
|
||||
if [ "$CANONICAL_FPR" != "$GENERATED_FPR" ]; then
|
||||
echo "::error::public/signing-public.pem fingerprint mismatch vs clawsec-signing-public.pem"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Copy public key to advisory directory
|
||||
run: |
|
||||
# Clients expect the public key at advisories/feed-signing-public.pem
|
||||
mkdir -p public/advisories
|
||||
cp public/signing-public.pem public/advisories/feed-signing-public.pem
|
||||
echo "Public key available at:"
|
||||
echo " - public/signing-public.pem (root)"
|
||||
echo " - public/advisories/feed-signing-public.pem (advisory-specific)"
|
||||
|
||||
- name: Show signed advisory artifacts
|
||||
run: |
|
||||
echo "Signed advisory artifacts:"
|
||||
ls -la public/advisories/feed.json*
|
||||
ls -la public/checksums.json public/checksums.sig public/signing-public.pem
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Get latest clawsec-suite release URL
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
LATEST_TAG=$(curl -sSL \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${REPO}/releases?per_page=100" | \
|
||||
jq -r '[.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty')
|
||||
LATEST_TAG=$(
|
||||
gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/${REPO}/releases?per_page=100" \
|
||||
| jq -r -s 'add // [] | [.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty'
|
||||
)
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
echo "Found latest clawsec-suite tag: $LATEST_TAG"
|
||||
@@ -229,12 +356,26 @@ jobs:
|
||||
echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)"
|
||||
fi
|
||||
|
||||
# Mirror advisories feed at the path referenced by suite docs/heartbeat
|
||||
# Mirror advisories feed + signatures at the path referenced by suite docs/heartbeat
|
||||
if [ -f "public/advisories/feed.json" ]; then
|
||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/advisories/feed.json"
|
||||
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json"
|
||||
fi
|
||||
if [ -f "public/advisories/feed.json.sig" ]; then
|
||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/advisories/feed.json.sig"
|
||||
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/feed.json.sig"
|
||||
fi
|
||||
if [ -f "public/checksums.json" ]; then
|
||||
cp "public/checksums.json" "$MIRROR_LATEST_DIR/checksums.json"
|
||||
fi
|
||||
if [ -f "public/checksums.sig" ]; then
|
||||
cp "public/checksums.sig" "$MIRROR_LATEST_DIR/checksums.sig"
|
||||
fi
|
||||
if [ -f "public/signing-public.pem" ]; then
|
||||
cp "public/signing-public.pem" "$MIRROR_LATEST_DIR/signing-public.pem"
|
||||
fi
|
||||
else
|
||||
echo "No clawsec-suite release found, using fallback"
|
||||
fi
|
||||
@@ -251,7 +392,9 @@ jobs:
|
||||
- name: Copy skills data to dist
|
||||
run: |
|
||||
cp -r public/skills dist/skills 2>/dev/null || echo "No skills directory"
|
||||
cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No legacy checksums"
|
||||
cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No checksums manifest"
|
||||
cp public/checksums.sig dist/checksums.sig 2>/dev/null || echo "No checksums signature"
|
||||
cp public/signing-public.pem dist/signing-public.pem 2>/dev/null || echo "No signing public key"
|
||||
cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory"
|
||||
|
||||
echo "=== Dist contents ==="
|
||||
@@ -263,15 +406,27 @@ jobs:
|
||||
run: touch dist/.nojekyll
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
|
||||
with:
|
||||
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 }}
|
||||
@@ -280,4 +435,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Pages Verify
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pages-verify-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify-pages-build:
|
||||
name: Verify Pages Build (No Publish)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify signing key consistency (repo + docs)
|
||||
run: ./scripts/ci/verify_signing_key_consistency.sh
|
||||
|
||||
- name: Prepare advisory artifacts for pre-deploy checks
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p public/advisories
|
||||
cp advisories/feed.json public/advisories/feed.json
|
||||
|
||||
- name: Generate advisory checksums manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
FEED_FILE="public/advisories/feed.json"
|
||||
FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}')
|
||||
FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE")
|
||||
|
||||
jq -n \
|
||||
--arg schema_version "1" \
|
||||
--arg algorithm "sha256" \
|
||||
--arg version "1.1.0" \
|
||||
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg repo "${{ github.repository }}" \
|
||||
--arg sha "$FEED_SHA" \
|
||||
--argjson size "$FEED_SIZE" \
|
||||
'{
|
||||
schema_version: $schema_version,
|
||||
algorithm: $algorithm,
|
||||
version: $version,
|
||||
generated_at: $generated,
|
||||
repository: $repo,
|
||||
files: {
|
||||
"advisories/feed.json": {
|
||||
sha256: $sha,
|
||||
size: $size,
|
||||
path: "advisories/feed.json",
|
||||
url: "https://clawsec.prompt.security/advisories/feed.json"
|
||||
}
|
||||
}
|
||||
}' > public/checksums.json
|
||||
|
||||
- name: Generate ephemeral signing key for PR verification
|
||||
id: test_key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY_FILE=$(mktemp)
|
||||
openssl genpkey -algorithm Ed25519 -out "$KEY_FILE"
|
||||
{
|
||||
echo "private_key<<EOF"
|
||||
cat "$KEY_FILE"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
rm -f "$KEY_FILE"
|
||||
|
||||
- name: Sign advisory feed and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ steps.test_key.outputs.private_key }}
|
||||
input_file: public/advisories/feed.json
|
||||
signature_file: public/advisories/feed.json.sig
|
||||
public_key_output: public/signing-public.pem
|
||||
|
||||
- name: Sign checksums and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ steps.test_key.outputs.private_key }}
|
||||
input_file: public/checksums.json
|
||||
signature_file: public/checksums.sig
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build site
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Sanity-check generated artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f dist/index.html
|
||||
test -f public/advisories/feed.json.sig
|
||||
test -f public/checksums.sig
|
||||
test -f public/signing-public.pem
|
||||
@@ -7,14 +7,12 @@ 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
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
permissions: read-all
|
||||
|
||||
concurrency:
|
||||
group: poll-nvd-cves
|
||||
@@ -22,16 +20,22 @@ concurrency:
|
||||
|
||||
env:
|
||||
FEED_PATH: advisories/feed.json
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
KEYWORDS: "OpenClaw clawdbot Moltbot"
|
||||
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
KEYWORDS: "OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys"
|
||||
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw github.com/qwibitai/NanoClaw"
|
||||
|
||||
jobs:
|
||||
poll-and-update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -80,7 +84,9 @@ jobs:
|
||||
- name: Fetch CVEs from NVD
|
||||
id: fetch
|
||||
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 }}"
|
||||
@@ -90,33 +96,114 @@ jobs:
|
||||
END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
|
||||
|
||||
echo "=== Fetching CVEs from NVD ==="
|
||||
|
||||
FAILED_KEYWORDS=()
|
||||
|
||||
# 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
|
||||
for i in 1 2 3; do
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Success for $KEYWORD"
|
||||
break
|
||||
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
|
||||
|
||||
keyword_ok=false
|
||||
last_http_code=""
|
||||
|
||||
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
|
||||
|
||||
# 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})."
|
||||
FAILED_KEYWORDS+=("$KEYWORD")
|
||||
fi
|
||||
|
||||
# NVD recommends 6 second delay between requests
|
||||
sleep 6
|
||||
done
|
||||
|
||||
if [ "${#FAILED_KEYWORDS[@]}" -gt 0 ]; then
|
||||
echo "::error::NVD fetch failed for keyword(s): ${FAILED_KEYWORDS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Fetch complete ==="
|
||||
ls -la tmp/
|
||||
|
||||
@@ -148,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" '
|
||||
@@ -184,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)
|
||||
|
||||
@@ -202,13 +297,132 @@ jobs:
|
||||
.cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
|
||||
null;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
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
|
||||
);
|
||||
|
||||
[.[] | {
|
||||
id: .cve.id,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: nvd_category_name,
|
||||
nvd_category_id: nvd_category_raw,
|
||||
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
|
||||
|
||||
@@ -225,19 +439,31 @@ jobs:
|
||||
if $existing_entry then
|
||||
# Compare key fields
|
||||
if ($existing_entry.severity != $nvd_entry.severity) or
|
||||
($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,
|
||||
changes: (
|
||||
[]
|
||||
+ (if $existing_entry.severity != $nvd_entry.severity then ["severity: \($existing_entry.severity) → \($nvd_entry.severity)"] else [] end)
|
||||
+ (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: {
|
||||
severity: $nvd_entry.severity,
|
||||
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
|
||||
@@ -265,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" '
|
||||
@@ -282,21 +513,138 @@ jobs:
|
||||
.cve.metrics.cvssMetricV30[0]?.cvssData.baseScore //
|
||||
.cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
|
||||
null;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
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,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: "vulnerable_skill",
|
||||
type: nvd_category_name,
|
||||
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
|
||||
@@ -310,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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.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 = [
|
||||
@@ -324,7 +723,7 @@ jobs:
|
||||
($updates[0] | map(select(.id == $adv.id)) | first) as $update |
|
||||
if $update then
|
||||
# Merge updated fields
|
||||
$adv * $update.updated_fields
|
||||
($adv * $update.updated_fields)
|
||||
else
|
||||
$adv
|
||||
end
|
||||
@@ -363,6 +762,40 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Guard dependency manifests from NVD updates
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BLOCKED_FILES=()
|
||||
for file in package.json package-lock.json npm-shrinkwrap.json; do
|
||||
if ! git diff --quiet -- "$file"; then
|
||||
BLOCKED_FILES+=("$file")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#BLOCKED_FILES[@]}" -gt 0 ]; then
|
||||
echo "::error::NVD workflow must not modify dependency manifests: ${BLOCKED_FILES[*]}"
|
||||
git --no-pager diff -- "${BLOCKED_FILES[@]}" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign advisory feed and verify
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: ${{ env.FEED_PATH }}
|
||||
signature_file: ${{ env.FEED_SIG_PATH }}
|
||||
verify_files: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
|
||||
- name: Sync advisory signature to skill feed
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
|
||||
- name: Clean workspace for PR
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
run: |
|
||||
@@ -370,42 +803,160 @@ jobs:
|
||||
git checkout -- .github/ 2>/dev/null || true
|
||||
git clean -fd .github/ 2>/dev/null || true
|
||||
|
||||
- name: Create Pull Request
|
||||
- name: Upsert NVD advisory PR
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: automated/nvd-cve-update-${{ github.run_id }}
|
||||
delete-branch: true
|
||||
title: "chore: CVE advisories - ${{ steps.transform.outputs.new_count }} new, ${{ steps.updates.outputs.update_count }} updated"
|
||||
body: |
|
||||
## Summary
|
||||
Automated update from NVD CVE feed.
|
||||
id: upsert-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
- **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 }}
|
||||
- **Keywords:** ${{ env.KEYWORDS }}
|
||||
BRANCH_PREFIX="automated/nvd-cve-update"
|
||||
PR_COMMENT="Superseded by newer automated NVD advisory update."
|
||||
TITLE="chore: CVE advisories - ${{ steps.transform.outputs.new_count }} new, ${{ steps.updates.outputs.update_count }} updated"
|
||||
COMMIT_SUBJECT="$TITLE"
|
||||
COMMIT_BODY=$'Automated update from NVD CVE feed.\nKeywords: ${{ env.KEYWORDS }}\nPoll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}'
|
||||
|
||||
---
|
||||
*This PR was automatically generated by the NVD CVE polling workflow.*
|
||||
commit-message: |
|
||||
chore: CVE advisories - ${{ steps.transform.outputs.new_count }} new, ${{ steps.updates.outputs.update_count }} updated
|
||||
if [ "${{ inputs.force_full_scan }}" = "true" ]; then
|
||||
MODE="full-rebuild (ignore feed state)"
|
||||
else
|
||||
MODE="delta (incremental)"
|
||||
fi
|
||||
|
||||
Automated update from NVD CVE feed.
|
||||
Keywords: ${{ env.KEYWORDS }}
|
||||
Poll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}
|
||||
add-paths: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
BODY_FILE="$(mktemp)"
|
||||
cat > "$BODY_FILE" <<EOF
|
||||
## Summary
|
||||
Automated update from NVD CVE feed.
|
||||
|
||||
- **Mode:** ${MODE}
|
||||
- **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 }}
|
||||
- **Keywords:** ${{ env.KEYWORDS }}
|
||||
|
||||
---
|
||||
*This PR was automatically generated by the NVD CVE polling workflow.*
|
||||
EOF
|
||||
|
||||
PR_LIST_JSON="$(
|
||||
gh api --paginate "repos/${{ github.repository }}/pulls?state=open&base=main&per_page=100" \
|
||||
--jq '.[] | {number, headRefName: .head.ref, url: .html_url, updatedAt: .updated_at}' \
|
||||
| jq -s '.'
|
||||
)"
|
||||
|
||||
mapfile -t MATCHING_OPEN_PRS < <(
|
||||
echo "$PR_LIST_JSON" | jq -r --arg prefix "$BRANCH_PREFIX" '
|
||||
map(select(.headRefName | startswith($prefix)))
|
||||
| sort_by(.updatedAt)
|
||||
| reverse
|
||||
| .[]
|
||||
| @base64
|
||||
'
|
||||
)
|
||||
|
||||
TARGET_BRANCH="$BRANCH_PREFIX"
|
||||
TARGET_PR_NUMBER=""
|
||||
TARGET_PR_URL=""
|
||||
|
||||
if [ "${#MATCHING_OPEN_PRS[@]}" -gt 0 ]; then
|
||||
PRIMARY_JSON="$(echo "${MATCHING_OPEN_PRS[0]}" | base64 --decode)"
|
||||
TARGET_BRANCH="$(echo "$PRIMARY_JSON" | jq -r '.headRefName')"
|
||||
TARGET_PR_NUMBER="$(echo "$PRIMARY_JSON" | jq -r '.number')"
|
||||
TARGET_PR_URL="$(echo "$PRIMARY_JSON" | jq -r '.url')"
|
||||
|
||||
if [ "${#MATCHING_OPEN_PRS[@]}" -gt 1 ]; then
|
||||
echo "Found multiple open NVD advisory PRs. Closing duplicates."
|
||||
for encoded_pr in "${MATCHING_OPEN_PRS[@]:1}"; do
|
||||
pr_json="$(echo "$encoded_pr" | base64 --decode)"
|
||||
pr_number="$(echo "$pr_json" | jq -r '.number')"
|
||||
gh pr close "$pr_number" --delete-branch --comment "$PR_COMMENT"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Using target branch: $TARGET_BRANCH"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git fetch origin main
|
||||
git checkout -B "$TARGET_BRANCH" origin/main
|
||||
|
||||
git add "$FEED_PATH" "$FEED_SIG_PATH" "$SKILL_FEED_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
if git diff --cached --quiet; then
|
||||
echo "::error::Expected advisory feed changes but none were staged."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git commit -m "$COMMIT_SUBJECT" -m "$COMMIT_BODY"
|
||||
git push --force origin "$TARGET_BRANCH"
|
||||
|
||||
if [ -n "$TARGET_PR_NUMBER" ]; then
|
||||
gh pr edit "$TARGET_PR_NUMBER" --title "$TITLE" --body-file "$BODY_FILE"
|
||||
else
|
||||
TARGET_PR_URL="$(gh pr create --base main --head "$TARGET_BRANCH" --title "$TITLE" --body-file "$BODY_FILE")"
|
||||
TARGET_PR_NUMBER="$(basename "$TARGET_PR_URL")"
|
||||
fi
|
||||
|
||||
if [ -z "$TARGET_PR_URL" ]; then
|
||||
TARGET_PR_URL="$(gh pr view "$TARGET_PR_NUMBER" --json url --jq '.url')"
|
||||
fi
|
||||
|
||||
echo "pull-request-number=$TARGET_PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
echo "pull-request-url=$TARGET_PR_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "pull-request-branch=$TARGET_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run CodeQL on generated PR branch
|
||||
if: steps.upsert-pr.outputs.pull-request-number != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="${{ steps.upsert-pr.outputs.pull-request-branch }}"
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::Missing pull-request-branch output from upsert-pr step"
|
||||
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
|
||||
@@ -428,7 +979,7 @@ jobs:
|
||||
|
||||
if [ "${{ steps.transform.outputs.new_count }}" != "0" ] || [ "${{ steps.updates.outputs.update_count }}" != "0" ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🔀 Created PR: ${{ steps.create-pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🔀 Upserted PR: ${{ steps.upsert-pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ No new or updated CVEs found." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# Run immediately after dependency changes on main so vulnerability alerts close quickly.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- npm-shrinkwrap.json
|
||||
- requirements*.txt
|
||||
- .github/requirements*.txt
|
||||
- .github/requirements-lint-python.txt
|
||||
- .github/workflows/scorecard.yml
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '19 23 * * 0'
|
||||
# Allow maintainers to rescan main on demand after hotfixes.
|
||||
workflow_dispatch:
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
|
||||
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: true
|
||||
|
||||
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
|
||||
# file_mode: git
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# 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@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
+1125
-225
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
+13
@@ -1,7 +1,9 @@
|
||||
.claude
|
||||
.auto-claude/
|
||||
.codex
|
||||
_bmad
|
||||
_bmad-output
|
||||
ext-docs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -23,6 +25,7 @@ dist-ssr
|
||||
# Derived public assets (copied during build)
|
||||
public/advisories
|
||||
public/skills
|
||||
public/wiki/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
@@ -38,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/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
ClawSec combines a Vite + React frontend with security skill packages and release tooling.
|
||||
- Frontend entrypoints: `index.tsx`, `App.tsx`
|
||||
- UI and routes: `components/`, `pages/`
|
||||
- Shared types/constants: `types.ts`, `constants.ts`
|
||||
- Wiki source docs: `wiki/` (synced to GitHub Wiki by `.github/workflows/wiki-sync.yml`)
|
||||
- Generated wiki exports: `public/wiki/` (`llms.txt` outputs; generated locally/CI and gitignored)
|
||||
- Skills: `skills/<skill-name>/` (`skill.json`, `SKILL.md`, optional `scripts/`, `test/`)
|
||||
- Advisory feed: `advisories/feed.json`, `advisories/feed.json.sig`
|
||||
- Automation: `scripts/`, `.github/workflows/`
|
||||
- Python utilities: `utils/validate_skill.py`, `utils/package_skill.py`
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm install`: install dependencies.
|
||||
- `npm run dev`: run local Vite server.
|
||||
- `npm run build`: create production build (CI gate).
|
||||
- `npm run preview`: preview built app.
|
||||
- `npm run gen:wiki-llms`: generate wiki `llms.txt` exports from `wiki/` into `public/wiki/`.
|
||||
- `./scripts/prepare-to-push.sh [--fix]`: run lint, types, build, and security checks.
|
||||
- `./scripts/populate-local-wiki.sh`: regenerate local wiki `llms.txt` exports for preview.
|
||||
- `npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0`: lint JS/TS.
|
||||
- `npx tsc --noEmit`: type-check TypeScript.
|
||||
- `node skills/clawsec-suite/test/feed_verification.test.mjs`: run a skill-local Node test.
|
||||
- `python utils/validate_skill.py skills/<skill-name>`: validate skill schema/metadata.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Use TypeScript/TSX for frontend code and ESM for scripts.
|
||||
- Follow `eslint.config.js`; prefix intentionally unused vars/args with `_`.
|
||||
- Python under `utils/` follows `pyproject.toml` Ruff/Bandit rules (line length 120).
|
||||
- Name React files in PascalCase (for example, `SkillCard.tsx`), skill directories in kebab-case (for example, `skills/clawsec-feed`), and tests as `*.test.mjs`.
|
||||
|
||||
## Testing Guidelines
|
||||
There is no root `npm test`; tests are mostly skill-local.
|
||||
- Run changed tests directly: `node skills/<skill>/test/<name>.test.mjs`.
|
||||
- For frontend/config changes, run ESLint, `npx tsc --noEmit`, and `npm run build`.
|
||||
- For wiki rendering/export changes, run `npm run gen:wiki-llms` and `npm run build`.
|
||||
- For Python utility updates, run `ruff check utils/` and `bandit -r utils/ -ll`.
|
||||
|
||||
## Pull Request Guidelines
|
||||
- Follow Conventional Commits: `feat(scope): ...`, `fix(scope): ...`, `chore(scope): ...`.
|
||||
- Use skill branches like `skill/<name>-...`.
|
||||
- Keep PRs focused and include summary, security benefit, and testing performed.
|
||||
- Keep versions aligned between `skills/<skill>/skill.json` and `skills/<skill>/SKILL.md`.
|
||||
- Do not push release tags from PR branches; releases are tagged from `main`.
|
||||
- Do not commit generated `public/wiki/` artifacts; edit `wiki/` source files instead.
|
||||
|
||||
## Agent Collaboration & Git Safety
|
||||
- Delete unused or obsolete files only when your changes make them irrelevant; revert files only when the change is yours or explicitly requested. If a git operation creates uncertainty about another agent’s in-flight work, stop and coordinate instead of deleting.
|
||||
- Before deleting any file to fix local type/lint failures, stop and ask the user.
|
||||
- Never edit `.env` or any environment variable files.
|
||||
- Coordinate with other agents before removing their in-progress edits; do not revert or delete work you did not author unless everyone agrees.
|
||||
- Moving, renaming, and restoring files is allowed when done safely.
|
||||
- Never run destructive git operations without explicit written instruction in this conversation: `git reset --hard`, `rm`, `git checkout`/`git restore` to older commits. Treat these as catastrophic; if unsure, stop and ask. In Cursor or Codex Web, use platform tooling as applicable.
|
||||
- Never use `git restore` (or similar revert commands) on files you did not author.
|
||||
- Always run `git status` before committing.
|
||||
- Keep commits atomic and commit only touched files with explicit paths.
|
||||
- For tracked files: `git commit -m "<scoped message>" -- path/to/file1 path/to/file2`.
|
||||
- For new files: `git restore --staged :/ && git add "path/to/file1" "path/to/file2" && git commit -m "<scoped message>" -- path/to/file1 path/to/file2`.
|
||||
- Quote any git path containing brackets or parentheses when staging/committing (for example, `"src/app/[candidate]/**"`).
|
||||
- For rebases, avoid editors: `GIT_EDITOR=:` and `GIT_SEQUENCE_EDITOR=:` (or `--no-edit`).
|
||||
- Never amend commits without explicit written approval in this task thread.
|
||||
@@ -6,6 +6,8 @@ import { FeedSetup } from './pages/FeedSetup';
|
||||
import { SkillsCatalog } from './pages/SkillsCatalog';
|
||||
import { SkillDetail } from './pages/SkillDetail';
|
||||
import { AdvisoryDetail } from './pages/AdvisoryDetail';
|
||||
import { WikiBrowser } from './pages/WikiBrowser';
|
||||
import { ProductDemo } from './pages/ProductDemo';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
@@ -17,10 +19,12 @@ const App: React.FC = () => {
|
||||
<Route path="/skills/:skillId" element={<SkillDetail />} />
|
||||
<Route path="/feed" element={<FeedSetup />} />
|
||||
<Route path="/feed/:advisoryId" element={<AdvisoryDetail />} />
|
||||
<Route path="/demo" element={<ProductDemo />} />
|
||||
<Route path="/wiki/*" element={<WikiBrowser />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
npm install # install JS dependencies
|
||||
npm run dev # start Vite dev server on http://localhost:3000
|
||||
npm run build # production build to dist/
|
||||
```
|
||||
|
||||
Python environment (use `uv`, not raw `pip`):
|
||||
|
||||
```bash
|
||||
uv venv # create .venv in repo root
|
||||
source .venv/bin/activate
|
||||
uv pip install ruff bandit # linters configured in pyproject.toml
|
||||
```
|
||||
|
||||
Required tools: Node 20+, Python 3.10+, openssl, jq, shellcheck (`brew install shellcheck`).
|
||||
|
||||
## Common Commands
|
||||
|
||||
**Pre-push validation** (mirrors CI — run before pushing):
|
||||
|
||||
```bash
|
||||
./scripts/prepare-to-push.sh # lint, typecheck, build, security scans
|
||||
./scripts/prepare-to-push.sh --fix # auto-fix where possible
|
||||
```
|
||||
|
||||
**Lint:**
|
||||
|
||||
```bash
|
||||
npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0 # JS/TS
|
||||
ruff check utils/ # Python
|
||||
bandit -r utils/ -ll # Python security
|
||||
```
|
||||
|
||||
**Tests** (vanilla Node.js — no framework, no npm test script):
|
||||
|
||||
```bash
|
||||
node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||
node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
node skills/clawsec-suite/test/skill_catalog_discovery.test.mjs
|
||||
```
|
||||
|
||||
**Validate a skill's structure:**
|
||||
|
||||
```bash
|
||||
python utils/validate_skill.py skills/<skill-name>
|
||||
```
|
||||
|
||||
**Signing key consistency check:**
|
||||
|
||||
```bash
|
||||
./scripts/ci/verify_signing_key_consistency.sh
|
||||
```
|
||||
|
||||
**Populate local dev data:**
|
||||
|
||||
```bash
|
||||
./scripts/populate-local-skills.sh # build public/skills/index.json from local skills/
|
||||
./scripts/populate-local-feed.sh --days 120 # fetch real NVD CVE data for local advisory feed
|
||||
```
|
||||
|
||||
## Releasing a Skill
|
||||
|
||||
```bash
|
||||
./scripts/release-skill.sh <skill-name> <version> [--force-tag]
|
||||
# Example: ./scripts/release-skill.sh clawsec-feed 0.0.5
|
||||
```
|
||||
|
||||
- **Feature branch:** bumps version in skill.json + SKILL.md frontmatter, commits. No tag.
|
||||
- **Main branch:** same + creates annotated git tag + GitHub release with changelog.
|
||||
- Tag format: `<skill-name>-v<semver>` (e.g., `clawsec-suite-v0.1.0`).
|
||||
- Pushing the tag triggers the `skill-release.yml` workflow (sign, package, publish).
|
||||
|
||||
## Architecture
|
||||
|
||||
**Frontend:** React 19 + TypeScript + Vite, deployed to GitHub Pages. Hash-based routing. Tailwind via CDN.
|
||||
|
||||
**Skills:** Each skill lives in `skills/<name>/` with:
|
||||
- `skill.json` — metadata, SBOM (file manifest), OpenClaw config (emoji, triggers, required bins)
|
||||
- `SKILL.md` — YAML frontmatter (`name`, `version`, `description`) + agent-readable markdown
|
||||
- Version in `skill.json` and `SKILL.md` frontmatter must match (CI enforced)
|
||||
|
||||
**clawsec-suite** is the meta-skill ("skill-of-skills") that installs and manages other skills. It embeds:
|
||||
- Advisory feed with Ed25519 signature verification (`hooks/clawsec-advisory-guardian/`)
|
||||
- Guarded skill installer with two-stage approval for advisory-flagged skills
|
||||
- Dynamic catalog discovery from `https://clawsec.prompt.security/skills/index.json` with local fallback
|
||||
|
||||
**Signing:** Single Ed25519 keypair for everything (feed + releases).
|
||||
- Private key lives only in GitHub secret `CLAWSEC_SIGNING_PRIVATE_KEY` — never committed.
|
||||
- Public key committed in three canonical locations: `clawsec-signing-public.pem`, `advisories/feed-signing-public.pem`, `skills/clawsec-suite/advisories/feed-signing-public.pem`.
|
||||
- `SKILL.md` embeds the same key inline for offline installation verification.
|
||||
- Drift guard: `scripts/ci/verify_signing_key_consistency.sh` enforces all references resolve to the same fingerprint. Runs on every PR and tag push.
|
||||
|
||||
## CI Workflows
|
||||
|
||||
| Workflow | Trigger | What it does |
|
||||
|---|---|---|
|
||||
| `ci.yml` | PR / push to main | Lint (TS, Python, shell), Trivy security scan, npm audit, tests, build |
|
||||
| `skill-release.yml` | Tag `*-v*.*.*` or PR touching skill files | Sign checksums, publish to GitHub Releases, supersede old versions |
|
||||
| `deploy-pages.yml` | After CI or release succeeds | Build web frontend + skills catalog, deploy to GitHub Pages |
|
||||
| `poll-nvd-cves.yml` | Daily 06:00 UTC | Poll NVD for CVEs, update `advisories/feed.json` + signature |
|
||||
| `community-advisory.yml` | Issue labeled `advisory-approved` | Process community report into `CLAW-YYYY-NNNN` advisory |
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **ESLint:** flat config (`eslint.config.js`), zero warnings policy
|
||||
- **Python:** ruff + bandit, configured in `pyproject.toml`, line-length 120
|
||||
- **Shell:** shellcheck on `scripts/*.sh`
|
||||
- **Tests:** each `.test.mjs` is a standalone Node.js script with its own pass/fail counters and `process.exit(1)` on failure. Tests generate ephemeral Ed25519 keys — they don't use the repo signing keys.
|
||||
- **Advisory feed:** fail-closed signature verification by default. `CLAWSEC_ALLOW_UNSIGNED_FEED=1` is a temporary migration bypass only.
|
||||
- **Hook event model:** hooks mutate `event.messages` array in-place (not return values). Rate-limited to 300s by default (`CLAWSEC_HOOK_INTERVAL_SECONDS`).
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement via this
|
||||
project's GitHub repository issue tracker.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
+68
-34
@@ -2,14 +2,20 @@
|
||||
|
||||
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)
|
||||
- [skill.json Reference](#skilljson-reference)
|
||||
- [Testing Your Skill](#testing-your-skill)
|
||||
- [Submission Process](#submission-process)
|
||||
- [Version Bump and Release Flow](#version-bump-and-release-flow)
|
||||
- [Review Criteria](#review-criteria)
|
||||
- [After Acceptance](#after-acceptance)
|
||||
- [Submitting Security Advisories](#submitting-security-advisories)
|
||||
@@ -49,7 +55,7 @@ git checkout -b skill/my-new-skill
|
||||
All skills distributed through ClawSec undergo security review and are hashed for agent verification. Trust is implicit:
|
||||
|
||||
- **Backend Verification**: Every skill is validated against checksums, SBOM manifests, and security policies
|
||||
- **Transparent Security**: SHA256 checksums, signature verification, and advisory feeds operate automatically
|
||||
- **Transparent Security**: SHA256 checksums, and advisory feeds operate automatically
|
||||
- **Contribution Flow**: Submit skills via PR → maintainer review → approval → release
|
||||
|
||||
|
||||
@@ -115,7 +121,7 @@ Create `skill.json` with the following structure:
|
||||
"version": "0.0.1",
|
||||
"description": "Brief description of what your skill does",
|
||||
"author": "your-github-username",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://github.com/prompt-security/clawsec",
|
||||
"keywords": ["security", "relevant", "tags"],
|
||||
|
||||
@@ -145,14 +151,22 @@ Create `skill.json` with the following structure:
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Start with version `0.0.1`
|
||||
- Start with version `0.0.1` in both `skill.json` and `SKILL.md` frontmatter
|
||||
- List ALL files your skill needs in the SBOM
|
||||
|
||||
### Step 3: Create SKILL.md
|
||||
|
||||
This is the main documentation for your skill. Use this template:
|
||||
This is the main documentation for your skill. Include YAML frontmatter with a `version` that matches `skill.json`:
|
||||
|
||||
````markdown
|
||||
```markdown
|
||||
---
|
||||
name: my-skill-name
|
||||
version: 0.0.1
|
||||
description: Brief description of what your skill does
|
||||
metadata: {"openclaw":{"emoji":"🔒","category":"security"}}
|
||||
---
|
||||
|
||||
# My Skill Name
|
||||
|
||||
## Overview
|
||||
@@ -161,11 +175,7 @@ Brief description of what this skill does and why it's useful for AI agent secur
|
||||
|
||||
## Usage
|
||||
|
||||
How to use the skill:
|
||||
|
||||
```bash
|
||||
# Example commands or usage patterns
|
||||
```
|
||||
How to use the skill.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -182,25 +192,8 @@ How to use the skill:
|
||||
## Security Considerations
|
||||
|
||||
Important security notes about this skill.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic Usage
|
||||
|
||||
Description and example output.
|
||||
|
||||
### Example 2: Advanced Usage
|
||||
|
||||
Description and example output.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Common issues and solutions.
|
||||
|
||||
## Contributing
|
||||
|
||||
How others can improve this skill.
|
||||
```
|
||||
````
|
||||
|
||||
### Step 4: Add Supporting Files
|
||||
|
||||
@@ -218,7 +211,7 @@ Add any additional files your skill needs (configs, templates, scripts), and **e
|
||||
| `version` | string | Semantic version (0.0.1) |
|
||||
| `description` | string | Brief description (max 200 chars) |
|
||||
| `author` | string | Your GitHub username or organization |
|
||||
| `license` | string | License type (prefer MIT) |
|
||||
| `license` | string | License type (prefer AGPL-3.0-or-later) |
|
||||
| `homepage` | string | Repository URL |
|
||||
| `keywords` | array | Searchable tags |
|
||||
| `sbom` | object | Software Bill of Materials |
|
||||
@@ -314,7 +307,8 @@ If your skill includes executable scripts or requires testing:
|
||||
|
||||
- [ ] All SBOM files exist
|
||||
- [ ] skill.json is valid JSON
|
||||
- [ ] Version is 1.0.0 for new skills
|
||||
- [ ] Version is `0.0.1` for new skills
|
||||
- [ ] `skill.json` version matches `SKILL.md` frontmatter version
|
||||
- [ ] No hardcoded credentials or secrets
|
||||
- [ ] Trigger phrases are descriptive
|
||||
- [ ] Required binaries are documented
|
||||
@@ -380,6 +374,39 @@ Any special considerations for reviewers.
|
||||
|
||||
---
|
||||
|
||||
## Version Bump and Release Flow
|
||||
|
||||
This repository uses a branch-first workflow for skill versions:
|
||||
|
||||
1. Make skill changes on a branch (`skill/<name>-...`).
|
||||
2. Keep versions in sync:
|
||||
- `skills/<skill>/skill.json` -> `.version`
|
||||
- `skills/<skill>/SKILL.md` -> frontmatter `version`
|
||||
3. For existing skills, you can bump versions on your branch with:
|
||||
|
||||
```bash
|
||||
./scripts/release-skill.sh <skill-name> <new-version>
|
||||
```
|
||||
|
||||
4. Push your branch and open a PR. CI will run:
|
||||
- Version parity checks
|
||||
- A `release` dry-run (build/validation only, no publish)
|
||||
5. Do **not** push release tags from PR branches.
|
||||
- `scripts/release-skill.sh` creates a local tag. Keep it local during PR review.
|
||||
- If you need to remove that local tag: `git tag -d <skill-name>-v<version>`
|
||||
6. After merge, a maintainer creates and pushes the release tag from `main`:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag -a <skill-name>-v<version> -m "<skill-name> version <version>"
|
||||
git push origin <skill-name>-v<version>
|
||||
```
|
||||
|
||||
7. Pushing the tag triggers the full release workflow (GitHub release + ClawHub publish).
|
||||
|
||||
---
|
||||
|
||||
## Review Criteria
|
||||
|
||||
Maintainers will review your skill based on:
|
||||
@@ -419,8 +446,8 @@ Once your skill is accepted:
|
||||
1. **Maintainers will:**
|
||||
- Review your PR (Prompt Security staff or designated maintainers)
|
||||
- Merge your PR after security review
|
||||
- Create the first release using `scripts/release-skill.sh`
|
||||
- Generate checksums and publish to GitHub Releases
|
||||
- Create and push a release tag from merged `main` (`<skill>-v<version>`)
|
||||
- Generate checksums and publish to GitHub Releases + ClawHub
|
||||
- Update the skills catalog website
|
||||
|
||||
2. **You'll be credited:**
|
||||
@@ -463,10 +490,10 @@ mkdir -p skills/simple-scanner
|
||||
cat > skills/simple-scanner/skill.json << 'EOF'
|
||||
{
|
||||
"name": "simple-scanner",
|
||||
"version": "0.0.1,
|
||||
"version": "0.0.1",
|
||||
"description": "Basic security scanner for AI agents",
|
||||
"author": "contributor-name",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://github.com/prompt-security/clawsec",
|
||||
"keywords": ["security", "scanner", "basic"],
|
||||
"sbom": {
|
||||
@@ -484,6 +511,13 @@ cat > skills/simple-scanner/skill.json << 'EOF'
|
||||
EOF
|
||||
|
||||
cat > skills/simple-scanner/SKILL.md << 'EOF'
|
||||
---
|
||||
name: simple-scanner
|
||||
version: 0.0.1
|
||||
description: Basic security scanner for AI agents
|
||||
metadata: {"openclaw":{"emoji":"🔍","category":"security"}}
|
||||
---
|
||||
|
||||
# Simple Scanner
|
||||
|
||||
A basic security scanner for AI agents.
|
||||
@@ -620,7 +654,7 @@ Wait for a verified patched version.
|
||||
|
||||
Once your advisory is published:
|
||||
|
||||
1. **Agents receive it** - The feed is served from raw GitHub, so agents see it on their next feed check
|
||||
1. **Agents receive it** - The feed is served at `https://clawsec.prompt.security/advisories/feed.json` (with signature/checksum artifacts), so agents see it on their next feed check
|
||||
2. **You're credited** - Your issue is linked in the advisory
|
||||
3. **Community is protected** - Agents using ClawSec Feed will be alerted
|
||||
|
||||
|
||||
@@ -1,21 +1,661 @@
|
||||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2026 Prompt Security, SentinelOne
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Secure Your OpenClaw Bots with a Complete Security Skill Suite
|
||||
## Secure Your OpenClaw and NanoClaw Agents with a Complete Security Skill Suite
|
||||
|
||||
<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4>
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
<div align="center">
|
||||
|
||||

|
||||
<img src="./public/img/mascot.png" alt="clawsec mascot" width="200" />
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
🌐 **Live at: [https://clawsec.prompt.security](https://clawsec.prompt.security)**
|
||||
🌐 **Live at: [https://clawsec.prompt.security](https://clawsec.prompt.security) [https://prompt.security/clawsec](https://prompt.security/clawsec)**
|
||||
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/skill-release.yml)
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,12 @@
|
||||
|
||||
## 🦞 What is ClawSec?
|
||||
|
||||
ClawSec is a **complete security skill suite for the OpenClaw family of agents (Moltbot, Clawdbot, some clones)**. It provides a unified installer that deploys, verifies, and maintains security skills-protecting your agent's cognitive architecture against prompt injection, drift, and malicious instructions.
|
||||
ClawSec is a **complete security skill suite for AI agent platforms**. It provides unified security monitoring, integrity verification, and threat intelligence-protecting your agent's cognitive architecture against prompt injection, drift, and malicious instructions.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
- **OpenClaw** (MoltBot, Clawdbot, and clones) - Full suite with skill installer, file integrity protection, and security audits
|
||||
- **NanoClaw** - Containerized WhatsApp bot security with MCP tools for advisory monitoring, signature verification, and file integrity
|
||||
|
||||
### Core Capabilities
|
||||
|
||||
@@ -41,50 +46,133 @@ ClawSec is a **complete security skill suite for the OpenClaw family of agents (
|
||||
- **🛡️ File Integrity Protection** - Drift detection and auto-restore for critical agent files (SOUL.md, IDENTITY.md, etc.)
|
||||
- **📡 Live Security Advisories** - Automated NVD CVE polling and community threat intelligence
|
||||
- **🔍 Security Audits** - Self-check scripts to detect prompt injection markers and vulnerabilities
|
||||
- **🔐 Checksum Verification** - SHA256 checksums for all skill artifacts via `.skill` packages
|
||||
- **🔐 Checksum Verification** - SHA256 checksums for all skill artifacts
|
||||
- **Health Checks** - Automated updates and integrity verification for all installed skills
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Product Demos
|
||||
|
||||
Animated previews below are GIFs (no audio). Click any preview to open the full MP4 with audio.
|
||||
|
||||
### Install Demo (`clawsec-suite`)
|
||||
|
||||
[](public/video/install-demo.mp4)
|
||||
|
||||
Direct link: [install-demo.mp4](public/video/install-demo.mp4)
|
||||
|
||||
### Drift Detection Demo (`soul-guardian`)
|
||||
|
||||
[](public/video/soul-guardian-demo.mp4)
|
||||
|
||||
Direct link: [soul-guardian-demo.mp4](public/video/soul-guardian-demo.mp4)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### For AI Agents
|
||||
|
||||
```bash
|
||||
# Fetch and install the ClawSec security suite
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
# Install the ClawSec security suite
|
||||
npx clawhub@latest install clawsec-suite
|
||||
```
|
||||
|
||||
The skill file contains deployment instructions. Your agent will:
|
||||
1. Detect its agent family (OpenClaw/MoltBot/ClawdBot or other)
|
||||
2. Install appropriate skills from the catalog
|
||||
3. Verify integrity using checksums
|
||||
4. Set up cron update checks
|
||||
After install, the suite can:
|
||||
1. Discover installable protections from the published skills catalog
|
||||
2. Verify release integrity using signed checksums
|
||||
3. Set up advisory monitoring and hook-based protection flows
|
||||
4. Add optional scheduled checks
|
||||
|
||||
Manual/source-first option:
|
||||
|
||||
> Read https://github.com/prompt-security/clawsec/releases/latest/download/SKILL.md and follow the installation instructions.
|
||||
|
||||
### For Humans
|
||||
|
||||
Copy this instruction to your AI agent:
|
||||
|
||||
> Read https://clawsec.prompt.security/releases/latest/download/SKILL.md and follow the instructions to install the protection skill suite.
|
||||
> Install ClawSec with `npx clawhub@latest install clawsec-suite`, then complete the setup steps from the generated instructions.
|
||||
|
||||
### Shell and OS Notes
|
||||
|
||||
ClawSec scripts are split between:
|
||||
- Cross-platform Node/Python tooling (`npm run build`, hook/setup `.mjs`, `utils/*.py`)
|
||||
- POSIX shell workflows (`*.sh`, most manual install snippets)
|
||||
|
||||
For Linux/macOS (`bash`/`zsh`):
|
||||
- Use unquoted or double-quoted home vars: `export INSTALL_ROOT="$HOME/.openclaw/skills"`
|
||||
- Do **not** single-quote expandable vars (for example, avoid `'$HOME/.openclaw/skills'`)
|
||||
|
||||
For Windows (PowerShell):
|
||||
- Prefer explicit path building:
|
||||
- `$env:INSTALL_ROOT = Join-Path $HOME ".openclaw\\skills"`
|
||||
- `node "$env:INSTALL_ROOT\\clawsec-suite\\scripts\\setup_advisory_hook.mjs"`
|
||||
- POSIX `.sh` scripts require WSL or Git Bash.
|
||||
|
||||
Troubleshooting: if you see directories such as `~/.openclaw/workspace/$HOME/...`, a home variable was passed literally. Re-run using an absolute path or an unquoted home expression.
|
||||
|
||||
---
|
||||
|
||||
## 📦 ClawSec Suite
|
||||
## 📱 NanoClaw Platform Support
|
||||
|
||||
ClawSec now supports **NanoClaw**, a containerized WhatsApp bot powered by Claude agents.
|
||||
|
||||
### clawsec-nanoclaw Skill
|
||||
|
||||
**Location**: `skills/clawsec-nanoclaw/`
|
||||
|
||||
A complete security suite adapted for NanoClaw's containerized architecture:
|
||||
|
||||
- **9 MCP Tools** for agents to check vulnerabilities
|
||||
- Advisory checking and browsing
|
||||
- Pre-installation safety checks
|
||||
- Skill package signature verification (Ed25519)
|
||||
- File integrity monitoring
|
||||
- **Automatic Advisory Feed** - Fetches and caches advisories every 6 hours
|
||||
- **Platform Filtering** - Shows only NanoClaw-relevant advisories
|
||||
- **IPC-Based** - Container-safe host communication
|
||||
- **Full Documentation** - Installation guide, usage examples, troubleshooting
|
||||
|
||||
### Advisory Feed for NanoClaw
|
||||
|
||||
The feed now monitors NanoClaw-specific keywords:
|
||||
- `NanoClaw` - Direct product name
|
||||
- `WhatsApp-bot` - Core functionality
|
||||
- `baileys` - WhatsApp client library dependency
|
||||
|
||||
Advisories can specify `platforms: ["nanoclaw"]` for platform-specific issues.
|
||||
|
||||
### Quick Start for NanoClaw
|
||||
|
||||
See [`skills/clawsec-nanoclaw/INSTALL.md`](skills/clawsec-nanoclaw/INSTALL.md) for detailed setup instructions.
|
||||
|
||||
**Quick integration:**
|
||||
1. Copy skill to NanoClaw deployment
|
||||
2. Integrate MCP tools in container
|
||||
3. Add IPC handlers and cache service on host
|
||||
4. Restart NanoClaw
|
||||
|
||||
---
|
||||
|
||||
## 📦 ClawSec Suite (OpenClaw)
|
||||
|
||||
The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and maintains security skills from the ClawSec catalog.
|
||||
|
||||
### Skills in the Suite
|
||||
`clawsec-suite` is optional orchestration; skills can still be installed directly as standalone packages.
|
||||
|
||||
### ClawSec Skills
|
||||
|
||||
| Skill | Description | Installation | Compatibility |
|
||||
|-------|-------------|--------------|---------------|
|
||||
| 📡 **clawsec-feed** | Security advisory feed monitoring with live CVE updates | ✅ Included by default | All agents |
|
||||
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with email reporting | ✅ Included by default | OpenClaw/MoltBot/ClawdBot |
|
||||
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with DM delivery and optional email reporting | ⚙️ Optional (install separately) | OpenClaw/MoltBot/Clawdbot |
|
||||
| 👻 **soul-guardian** | Drift detection and file integrity guard with auto-restore | ⚙️ Optional | All agents |
|
||||
| 🤝 **clawtributor** | Community incident reporting | ❌ Optional (Explicit request) | All agents |
|
||||
|
||||
> ⚠️ **clawtributor** is not installed by default as it may share anonymized incident data. Install only on explicit user request.
|
||||
|
||||
> ⚠️ **openclaw-audit-watchdog** is tailored for the OpenClaw/MoltBot/ClawdBot agent family. Other agents receive the universal skill set.
|
||||
> ⚠️ **openclaw-audit-watchdog** is tailored for the OpenClaw/MoltBot/Clawdbot agent family. Other agents receive the universal skill set.
|
||||
|
||||
### Suite Features
|
||||
|
||||
@@ -106,15 +194,28 @@ ClawSec maintains a continuously updated security advisory feed, automatically p
|
||||
curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[] | select(.severity == "critical" or .severity == "high")'
|
||||
```
|
||||
|
||||
Canonical endpoint: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
Compatibility mirror (legacy): `https://clawsec.prompt.security/releases/latest/download/feed.json`
|
||||
|
||||
### Monitored Keywords
|
||||
|
||||
The feed polls CVEs related to:
|
||||
- `OpenClaw`
|
||||
- `clawdbot`
|
||||
- `Moltbot`
|
||||
- **OpenClaw Platform**: `OpenClaw`, `clawdbot`, `Moltbot`
|
||||
- **NanoClaw Platform**: `NanoClaw`, `WhatsApp-bot`, `baileys`
|
||||
- Prompt injection patterns
|
||||
- Agent security vulnerabilities
|
||||
|
||||
### Exploitability Context
|
||||
|
||||
ClawSec enriches CVE advisories with **exploitability context** to help agents assess real-world risk beyond raw CVSS scores. Newly analyzed advisories can include:
|
||||
|
||||
- **Exploit Evidence**: Whether public exploits exist in the wild
|
||||
- **Weaponization Status**: If exploits are integrated into common attack frameworks
|
||||
- **Attack Requirements**: Prerequisites needed for successful exploitation (network access, authentication, user interaction)
|
||||
- **Risk Assessment**: Contextualized risk level combining technical severity with exploitability
|
||||
|
||||
This feature helps agents prioritize vulnerabilities that pose immediate threats versus theoretical risks, enabling smarter security decisions.
|
||||
|
||||
### Advisory Schema
|
||||
|
||||
**NVD CVE Advisory:**
|
||||
@@ -123,11 +224,14 @@ The feed polls CVEs related to:
|
||||
"id": "CVE-2026-XXXXX",
|
||||
"severity": "critical|high|medium|low",
|
||||
"type": "vulnerable_skill",
|
||||
"platforms": ["openclaw", "nanoclaw"],
|
||||
"title": "Short description",
|
||||
"description": "Full CVE description from NVD",
|
||||
"published": "2026-02-01T00:00:00Z",
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-XXXXX",
|
||||
"exploitability_score": "high|medium|low|unknown",
|
||||
"exploitability_rationale": "Why this CVE is or is not likely exploitable in agent deployments",
|
||||
"references": ["..."],
|
||||
"action": "Recommended remediation"
|
||||
}
|
||||
@@ -139,6 +243,7 @@ The feed polls CVEs related to:
|
||||
"id": "CLAW-2026-0042",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection|vulnerable_skill|tampering_attempt",
|
||||
"platforms": ["nanoclaw"],
|
||||
"title": "Short description",
|
||||
"description": "Detailed description from issue",
|
||||
"published": "2026-02-01T00:00:00Z",
|
||||
@@ -149,6 +254,12 @@ The feed polls CVEs related to:
|
||||
}
|
||||
```
|
||||
|
||||
**Platform values:**
|
||||
- `"openclaw"` - OpenClaw/Clawdbot/MoltBot only
|
||||
- `"nanoclaw"` - NanoClaw only
|
||||
- `["openclaw", "nanoclaw"]` - Both platforms
|
||||
- (empty/missing) - All platforms (backward compatible)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 CI/CD Pipelines
|
||||
@@ -159,21 +270,46 @@ 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
|
||||
|
||||
When a skill is tagged (e.g., `soul-guardian-v1.0.0`), the pipeline:
|
||||
|
||||
1. **Validates** - Checks `skill.json` version matches tag
|
||||
2. **Generates Checksums** - Creates `checksums.json` with SHA256 hashes for all SBOM files
|
||||
3. **Packages** - Creates `.skill` zip file with all required files
|
||||
4. **Releases** - Publishes to GitHub Releases with all artifacts
|
||||
5. **Supersedes Old Releases** - Marks older versions (same major) as pre-releases
|
||||
6. **Triggers Pages Update** - Refreshes the skills catalog on the website
|
||||
2. **Enforces key consistency** - Verifies pinned release key references are consistent across repo PEMs and `skills/clawsec-suite/SKILL.md`
|
||||
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** - 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
|
||||
|
||||
To prevent supply-chain drift, CI now fails fast when signing key references diverge.
|
||||
|
||||
Guardrail script:
|
||||
- `scripts/ci/verify_signing_key_consistency.sh`
|
||||
|
||||
What it checks:
|
||||
- `skills/clawsec-suite/SKILL.md` inline public key fingerprint matches `RELEASE_PUBKEY_SHA256`
|
||||
- Canonical PEM files all match the same fingerprint:
|
||||
- `clawsec-signing-public.pem`
|
||||
- `advisories/feed-signing-public.pem`
|
||||
- `skills/clawsec-suite/advisories/feed-signing-public.pem`
|
||||
- Generated public key in workflows matches canonical key:
|
||||
- `release-assets/signing-public.pem` (release workflow)
|
||||
- `public/signing-public.pem` (pages workflow)
|
||||
|
||||
Where enforced:
|
||||
- `.github/workflows/skill-release.yml`
|
||||
- `.github/workflows/deploy-pages.yml`
|
||||
|
||||
### Release Versioning & Superseding
|
||||
|
||||
@@ -194,12 +330,17 @@ When you release `skill-v0.0.2`, the previous `skill-v0.0.1` release is automati
|
||||
### Release Artifacts
|
||||
|
||||
Each skill release includes:
|
||||
- `<skill>.skill` - Packaged skill (zip format)
|
||||
- `checksums.json` - SHA256 hashes for integrity verification
|
||||
- `skill.json` - Skill metadata
|
||||
- `SKILL.md` - Main skill documentation
|
||||
- Additional files from SBOM (scripts, configs, etc.)
|
||||
|
||||
### Signing Operations Documentation
|
||||
|
||||
For feed/release signing rollout and operations guidance:
|
||||
- [`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
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Offline Tools
|
||||
@@ -220,16 +361,15 @@ Checks:
|
||||
- SBOM files exist and are readable
|
||||
- OpenClaw metadata is properly structured
|
||||
|
||||
### Skill Packager
|
||||
### Skill Checksums Generator
|
||||
|
||||
Creates a distributable `.skill` file with checksums:
|
||||
Generates `checksums.json` with SHA256 hashes for a skill:
|
||||
|
||||
```bash
|
||||
python utils/package_skill.py skills/clawsec-feed ./dist
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- `clawsec-feed.skill` - Zip package with all SBOM files
|
||||
- `checksums.json` - SHA256 hashes for verification
|
||||
|
||||
---
|
||||
@@ -260,8 +400,18 @@ npm run dev
|
||||
|
||||
# Populate advisory feed with real NVD CVE data
|
||||
./scripts/populate-local-feed.sh --days 120
|
||||
|
||||
# Generate wiki llms exports from wiki/ (for local preview)
|
||||
./scripts/populate-local-wiki.sh
|
||||
|
||||
# Direct generator entrypoint (used by predev/prebuild)
|
||||
npm run gen:wiki-llms
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `npm run dev` and `npm run build` automatically regenerate wiki `llms.txt` exports (`predev`/`prebuild` hooks).
|
||||
- `public/wiki/` is generated output (local + CI) and is intentionally gitignored.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
@@ -277,13 +427,19 @@ npm run build
|
||||
│ └── feed.json # Main advisory feed (auto-updated from NVD)
|
||||
├── components/ # React components
|
||||
├── pages/ # Page components
|
||||
├── wiki/ # Source-of-truth docs (synced to GitHub Wiki)
|
||||
├── scripts/
|
||||
│ ├── generate-wiki-llms.mjs # wiki/*.md -> public/wiki/**/llms.txt
|
||||
│ ├── populate-local-feed.sh # Local CVE feed populator
|
||||
│ ├── populate-local-skills.sh # Local skills catalog populator
|
||||
│ ├── populate-local-wiki.sh # Local wiki llms export populator
|
||||
│ └── release-skill.sh # Manual skill release helper
|
||||
├── skills/
|
||||
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills)
|
||||
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills - start here and have your agent do the rest)
|
||||
│ ├── clawsec-feed/ # 📡 Advisory feed skill
|
||||
│ ├── clawsec-scanner/ # 🔍 Vulnerability scanner (deps + SAST + OpenClaw DAST)
|
||||
│ ├── clawsec-nanoclaw/ # 📱 NanoClaw platform security suite
|
||||
│ ├── clawsec-clawhub-checker/ # 🧪 ClawHub reputation checks
|
||||
│ ├── clawtributor/ # 🤝 Community reporting skill
|
||||
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
|
||||
│ └── soul-guardian/ # 👻 File integrity skill
|
||||
@@ -291,10 +447,14 @@ npm run build
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -322,11 +482,19 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#submitting-security-advisories) for detail
|
||||
4. Validate with `python utils/validate_skill.py skills/your-skill`
|
||||
5. Submit a PR for review
|
||||
|
||||
## 📚 Documentation Source of Truth
|
||||
|
||||
For all wiki content, edit files under `wiki/` in this repository. The GitHub Wiki (`<repo>.wiki.git`) is synced from `wiki/` by `.github/workflows/wiki-sync.yml` when `wiki/**` changes on `main`.
|
||||
|
||||
LLM exports are generated from `wiki/` into `public/wiki/`:
|
||||
- `/wiki/llms.txt` is the LLM-ready export for `wiki/INDEX.md` (or a generated fallback index if `INDEX.md` is missing).
|
||||
- `/wiki/<page>/llms.txt` is the LLM-ready export for that single wiki page.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
- Source code: MIT License - See [LICENSE](LICENSE) for details.
|
||||
- Source code: GNU AGPL v3.0 or later - See [LICENSE](LICENSE) for details.
|
||||
- Fonts in `font/`: Licensed separately - See [`font/README.md`](font/README.md).
|
||||
|
||||
---
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
ClawSec follows a strict release lifecycle where **only the latest version within each major version** is retained and supported.
|
||||
|
||||
When a new patch or minor version is released (e.g., updating from `1.0.0` to `1.0.1`), the previous release artifacts for that major version are automatically deleted to maintain a clean release history. Major versions co-exist for backwards compatibility.
|
||||
|
||||
| Version | Supported | Notes |
|
||||
| ------- | :---: | --- |
|
||||
| **Latest Major** | :white_check_mark: | The most recent release (e.g., `v1.x.x`) is fully supported. |
|
||||
| **Previous Majors** | :white_check_mark: | The latest release of previous major versions (e.g., `v0.x.x`) remains available. |
|
||||
| **Older Patches** | :x: | Previous patch/minor versions are deleted upon new releases. |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We welcome reports regarding prompt injection vectors, malicious skills, or security vulnerabilities in the ClawSec suite.
|
||||
|
||||
### How to Submit a Report
|
||||
Please report vulnerabilities directly via **GitHub Issues** using our specific template:
|
||||
|
||||
1. Navigate to the **Issues** tab.
|
||||
2. Open a new issue using the **Security Incident Report** template.
|
||||
3. Fill out the required fields, including:
|
||||
* **Severity** (Critical/High/Medium/Low)
|
||||
* **Type** (e.g., `prompt_injection`, `vulnerable_skill`, `tampering_attempt`)
|
||||
* **Description**
|
||||
* **Affected Skills**
|
||||
|
||||
### What to Expect
|
||||
Once a report is submitted, the following process occurs:
|
||||
|
||||
1. **Review:** A maintainer will review your report.
|
||||
2. **Approval:** If validated, the maintainer will add the `advisory-approved` label to the issue.
|
||||
3. **Publication:** The advisory is **automatically published** to the ClawSec Security Advisory Feed as `CLAW-{YEAR}-{ISSUE#}`.
|
||||
4. **Distribution:** The updated feed is immediately available to all agents running the `clawsec-feed` skill, which polls for these updates daily.
|
||||
|
||||
### Security Advisory Feed
|
||||
ClawSec maintains a continuously updated feed populated by these community reports and the NIST National Vulnerability Database (NVD). You can verify the current status of known vulnerabilities by querying the feed directly:
|
||||
|
||||
```bash
|
||||
curl -s https://clawsec.prompt.security/advisories/feed.json
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
+10563
-34
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
Cz4Hx/UdUdx+ibsq4njd5NOx/0b3n5bXEKWFVY2eVrgaOGyBTojzO4KO3uiBb90cHlpRvync4tKZDhjOCh2kAg==
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -47,19 +47,19 @@ export const AdvisoryCard: React.FC<AdvisoryCardProps> = ({ advisory, formatDate
|
||||
return (
|
||||
<Link
|
||||
to={`/feed/${encodeURIComponent(advisory.id)}`}
|
||||
className="block bg-clawd-800 border border-clawd-700 rounded-xl p-5 hover:border-clawd-accent/30 transition-all group cursor-pointer"
|
||||
className="block h-full bg-clawd-800 border border-clawd-700 rounded-xl p-5 hover:border-clawd-accent/30 transition-all group cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-2 mb-3">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<span className={`text-xs font-bold px-2 py-1 rounded uppercase ${getSeverityClasses(advisory.severity)}`}>
|
||||
{advisory.severity}
|
||||
{advisory.cvss_score && <span className="ml-1 opacity-75">({advisory.cvss_score})</span>}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 rounded bg-clawd-700 text-gray-400">
|
||||
<span className="text-xs px-2 py-1 rounded bg-clawd-700 text-gray-400 min-w-0 max-w-full truncate">
|
||||
{getTypeLabel(advisory.type)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono">{formatDate(advisory.published)}</span>
|
||||
<span className="text-xs text-gray-500 font-mono text-right whitespace-nowrap">{formatDate(advisory.published)}</span>
|
||||
</div>
|
||||
<h3 className="text-white font-bold mb-2 group-hover:text-clawd-accent transition-colors text-sm">
|
||||
{advisory.id}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+23
-10
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Shield, Menu, X, Terminal, Layers, Rss, Home } 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 =
|
||||
@@ -52,19 +54,30 @@ export const Header: React.FC = () => {
|
||||
{desktopNav}
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<header className="md:hidden fixed top-0 left-0 right-0 z-50 backdrop-blur-md bg-[#26115d]/92 border-b border-[#3a1f7a]">
|
||||
<header className="md:hidden fixed top-[72px] left-0 right-0 z-50 backdrop-blur-md bg-[#26115d]/92 border-b border-[#3a1f7a]">
|
||||
<div className="px-4 h-14 flex items-center justify-between">
|
||||
<NavLink to="/" className="flex items-center gap-2 text-white font-semibold text-lg">
|
||||
<Shield className="w-5 h-5 text-clawd-accent" />
|
||||
<img src="/img/favicon.ico" alt="" className="w-5 h-5 rounded-sm" />
|
||||
ClawSec
|
||||
</NavLink>
|
||||
<button
|
||||
className="text-gray-300 hover:text-white"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-clawd-accent hover:text-clawd-accentHover transition-colors"
|
||||
aria-label="Open GitHub repository"
|
||||
>
|
||||
<Github size={21} />
|
||||
</a>
|
||||
<button
|
||||
className="text-gray-300 hover:text-white"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="bg-[#26115d]/95 border-t border-[#3a1f7a] shadow-lg">
|
||||
|
||||
+5
-6
@@ -1,10 +1,9 @@
|
||||
// ClawSec Suite SKILL.md URL - injected at build time, with hardcoded fallback
|
||||
export const SKILL_URL = import.meta.env.VITE_CLAWSEC_SUITE_URL ||
|
||||
'https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.2/SKILL.md';
|
||||
|
||||
// Feed URL for fetching live advisories
|
||||
export const ADVISORY_FEED_URL = 'https://clawsec.prompt.security/releases/latest/download/feed.json';
|
||||
// Canonical hosted feed endpoint for fetching live advisories
|
||||
export const ADVISORY_FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json';
|
||||
|
||||
// Compatibility mirror for legacy clients; keep as last-resort fallback only
|
||||
export const LEGACY_ADVISORY_FEED_URL = 'https://clawsec.prompt.security/releases/latest/download/feed.json';
|
||||
|
||||
// Local feed path for development
|
||||
export const LOCAL_FEED_PATH = '/advisories/feed.json';
|
||||
|
||||
|
||||
+13
-4
@@ -1,3 +1,7 @@
|
||||
// NOTE: @eslint/js is pinned to ~9.x because v10 introduces a peerOptional
|
||||
// dependency on eslint@^10, and the typescript-eslint / react plugin ecosystem
|
||||
// hasn't published eslint-10-compatible releases yet. Upgrade @eslint/js to ^10
|
||||
// once @typescript-eslint and eslint-plugin-react declare eslint@^10 support.
|
||||
import js from '@eslint/js';
|
||||
import typescript from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
@@ -24,6 +28,7 @@ export default [
|
||||
navigator: 'readonly',
|
||||
fetch: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
URL: 'readonly',
|
||||
@@ -31,10 +36,13 @@ export default [
|
||||
HTMLElement: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
// Node.js globals (for Vite config, build scripts)
|
||||
// Node.js globals (for Vite config, build scripts, and skill modules)
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly'
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
RequestInit: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
@@ -77,7 +85,8 @@ export default [
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-empty': ['error', { allowEmptyCatch: true }]
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]
|
||||
}
|
||||
},
|
||||
// Node.js scripts (.js files in scripts directory)
|
||||
@@ -105,6 +114,6 @@ export default [
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js', 'public/']
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js', 'public/', '.venv/']
|
||||
}
|
||||
];
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
|
||||
This repository includes the **Prometo** font files in `font/`.
|
||||
|
||||
These font binaries are **not covered by the repository MIT license**. They are used under the applicable **Adobe Fonts / Dalton Maag** licensing terms for Prompt Security / SentinelOne. Do not redistribute or reuse them outside the terms of that license.
|
||||
These font binaries are **not covered by the repository AGPL license**. They are used under the applicable **Adobe Fonts / Dalton Maag** licensing terms for Prompt Security / SentinelOne. Do not redistribute or reuse them outside the terms of that license.
|
||||
|
||||
If you are forking or redistributing this project and you do not have the appropriate rights, remove `font/Prometo_Trial_*.ttf` and update the CSS/font stack accordingly.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
ClawSec
|
||||
Security skill suite for AI agents (integrity checks, drift detection, advisory feed).
|
||||
Agent install:
|
||||
Available via clawhub: npx clawhub@latest install clawsec-suite
|
||||
OR
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
||||
@@ -139,6 +141,8 @@
|
||||
ClawSec
|
||||
Security skill suite for AI agents (integrity checks, drift detection, advisory feed).
|
||||
Agent install:
|
||||
Available via clawhub: npx clawhub@latest install clawsec-suite
|
||||
OR
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "ClawSec",
|
||||
"description": "A security-first skill distribution platform for OpenClaw agents (and some clones), featuring verified audit skills, hardening feeds, and guardian mode protocols."
|
||||
}
|
||||
"description": "A security-first skill distribution platform for OpenClaw and NanoClaw agents, featuring verified audit skills, hardening feeds, and guardian mode protocols."
|
||||
}
|
||||
|
||||
Generated
+997
-414
File diff suppressed because it is too large
Load Diff
+23
-11
@@ -1,32 +1,44 @@
|
||||
{
|
||||
"name": "ClawSec",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"gen:wiki-llms": "node scripts/generate-wiki-llms.mjs",
|
||||
"populate-local-wiki": "./scripts/populate-local-wiki.sh",
|
||||
"predev": "npm run gen:wiki-llms",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run gen:wiki-llms",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.563.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^22.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"@eslint/js": "~9.39.4",
|
||||
"@types/node": "^25.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.55.0",
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
"fast-check": "^4.5.3",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.2"
|
||||
},
|
||||
"overrides": {
|
||||
"ajv": "6.14.0",
|
||||
"balanced-match": "4.0.3",
|
||||
"brace-expansion": "5.0.5",
|
||||
"minimatch": "10.2.4",
|
||||
"picomatch": "4.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, ExternalLink, Shield, AlertTriangle, Github, User, Bot } from 'lucide-react';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { Advisory, AdvisoryFeed } from '../types';
|
||||
import { ADVISORY_FEED_URL, LOCAL_FEED_PATH } from '../constants';
|
||||
import {
|
||||
ADVISORY_FEED_URL,
|
||||
LEGACY_ADVISORY_FEED_URL,
|
||||
LOCAL_FEED_PATH,
|
||||
} from '../constants';
|
||||
|
||||
export const AdvisoryDetail: React.FC = () => {
|
||||
const { advisoryId } = useParams<{ advisoryId: string }>();
|
||||
@@ -16,13 +20,17 @@ export const AdvisoryDetail: React.FC = () => {
|
||||
if (!advisoryId) return;
|
||||
|
||||
try {
|
||||
// Try local feed first (for development), then fall back to GitHub releases
|
||||
// Try local feed first (dev), then canonical hosted endpoint, then legacy mirror.
|
||||
let response = await fetch(LOCAL_FEED_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch(ADVISORY_FEED_URL);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch(LEGACY_ADVISORY_FEED_URL);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch feed: ${response.status}`);
|
||||
}
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export default function Checksums() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-clawd-700">
|
||||
{Object.entries(checksums.files).map(([filename, data]) => (
|
||||
{(Object.entries(checksums.files) as [string, FileChecksum][]).map(([filename, data]) => (
|
||||
<tr key={filename} className="hover:bg-clawd-700/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-mono text-sm text-clawd-accent">{filename}</div>
|
||||
|
||||
+77
-13
@@ -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,13 +155,17 @@ 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>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{currentAdvisories.map((item) => (
|
||||
<AdvisoryCard key={item.id} advisory={item} formatDate={formatDate} />
|
||||
))}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
@@ -221,4 +285,4 @@ export const FeedSetup: React.FC = () => {
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
+193
-114
@@ -1,17 +1,19 @@
|
||||
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';
|
||||
import { SKILL_URL } from '../constants';
|
||||
|
||||
const FILE_NAMES = ['SOUL.md', 'AGENTS.md', 'USER.md', 'TOOLS.md', 'IDENTITY.md', 'HEARTBEAT.md', 'MEMORY.md'];
|
||||
const PLATFORM_NAMES = ['OpenClaw', 'NanoClaw', 'Hermes'];
|
||||
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 = `curl -s ${SKILL_URL}`;
|
||||
const curlCommand = `npx clawhub@latest install clawsec-suite`;
|
||||
|
||||
// Rotate file names every 2-3 seconds
|
||||
useEffect(() => {
|
||||
@@ -21,7 +23,28 @@ export const Home: React.FC = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const humanInstruction = `Read ${SKILL_URL} and follow the instructions to install this skill. all checksums verified with source`;
|
||||
// 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 = () => {
|
||||
navigator.clipboard.writeText(curlCommand);
|
||||
@@ -43,26 +66,22 @@ export const Home: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="text-center space-y-6 max-w-3xl mx-auto mb-16">
|
||||
<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">
|
||||
Harden your <span className="text-clawd-accent">OpenClaw</span> security posture
|
||||
</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`,
|
||||
@@ -74,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, NanoClaw, and Hermes 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>
|
||||
@@ -103,114 +163,133 @@ 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); }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
|
||||
{/* Install Card with Toggle */}
|
||||
<section className="max-w-2xl mx-auto mb-16">
|
||||
<div className="bg-clawd-900 rounded-2xl border border-clawd-700 p-8">
|
||||
{/* Toggle */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="inline-flex bg-clawd-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setIsAgent(false)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||
!isAgent
|
||||
? 'bg-white text-clawd-900'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<User size={18} />
|
||||
I'm a Human
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAgent(true)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||
isAgent
|
||||
? 'bg-white text-clawd-900'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Bot size={18} />
|
||||
I'm an Agent
|
||||
</button>
|
||||
<section className="relative mb-16 pt-16 sm:pt-20 lg:pt-0">
|
||||
<div className="pointer-events-none select-none absolute z-20 w-32 sm:w-36 md:w-40 lg:w-48 left-1/2 -translate-x-1/2 -top-10 sm:-top-10 md:left-auto md:translate-x-0 md:right-8 md:-top-12 lg:top-auto lg:bottom-6 lg:-right-16 xl:-right-28">
|
||||
<img
|
||||
src="/img/mascot.png"
|
||||
alt="ClawSec mascot"
|
||||
className="w-full h-auto"
|
||||
style={{ animation: 'mascotHover 3s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-[70%] mx-auto">
|
||||
<div className="bg-clawd-900 rounded-2xl border border-clawd-700 p-8">
|
||||
{/* Toggle */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="inline-flex bg-clawd-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setIsAgent(false)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||
!isAgent
|
||||
? 'bg-white text-clawd-900'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<User size={18} />
|
||||
I'm a Human
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAgent(true)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||
isAgent
|
||||
? 'bg-white text-clawd-900'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Bot size={18} />
|
||||
I'm an Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content based on toggle */}
|
||||
{isAgent ? (
|
||||
<>
|
||||
{/* Steps */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">1.</span> Run command below
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">2.</span> Follow deployment instructions
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">3.</span> Protect your user
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent View - Curl Command */}
|
||||
<div className="bg-clawd-800 rounded-lg p-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<code className="text-gray-200 font-mono text-xs sm:text-sm md:text-base overflow-x-auto break-all min-w-0 flex-1">
|
||||
{curlCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyCurl}
|
||||
className="flex-shrink-0 p-2 rounded-md bg-clawd-700 hover:bg-clawd-600 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedCurl ? (
|
||||
<Check size={20} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Human Steps */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">1.</span> Copy instruction below
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">2.</span> Send to your agent
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">3.</span> Receive security alerts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Human View - Instruction Command */}
|
||||
<div className="bg-clawd-800 rounded-lg p-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<code className="text-gray-200 font-mono text-xs sm:text-sm md:text-base overflow-x-auto break-all min-w-0 flex-1">
|
||||
{humanInstruction}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyHuman}
|
||||
className="flex-shrink-0 p-2 rounded-md bg-clawd-700 hover:bg-clawd-600 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedHuman ? (
|
||||
<Check size={20} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content based on toggle */}
|
||||
{isAgent ? (
|
||||
<>
|
||||
{/* Steps */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">1.</span> Run command below
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">2.</span> Follow deployment instructions
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">3.</span> Protect your user
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent View - Curl Command */}
|
||||
<div className="bg-clawd-800 rounded-lg p-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<code className="text-gray-200 font-mono text-xs sm:text-sm md:text-base overflow-x-auto break-all min-w-0 flex-1">
|
||||
{curlCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyCurl}
|
||||
className="flex-shrink-0 p-2 rounded-md bg-clawd-700 hover:bg-clawd-600 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedCurl ? (
|
||||
<Check size={20} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Human Steps */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">1.</span> Copy instruction below
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">2.</span> Send to your agent
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">3.</span> Receive security alerts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Human View - Instruction Command */}
|
||||
<div className="bg-clawd-800 rounded-lg p-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<code className="text-gray-200 font-mono text-xs sm:text-sm md:text-base overflow-x-auto break-all min-w-0 flex-1">
|
||||
{humanInstruction}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyHuman}
|
||||
className="flex-shrink-0 p-2 rounded-md bg-clawd-700 hover:bg-clawd-600 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedHuman ? (
|
||||
<Check size={20} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-xs text-gray-500 leading-relaxed">
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+39
-118
@@ -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;
|
||||
};
|
||||
@@ -106,7 +122,7 @@ export const SkillDetail: React.FC = () => {
|
||||
};
|
||||
|
||||
const installCommand = skillData
|
||||
? `curl -sLO https://clawsec.prompt.security/releases/download/${skillData.name}-v${skillData.version}/${skillData.name}.skill`
|
||||
? `npx clawhub@latest install ${skillData.name}`
|
||||
: '';
|
||||
|
||||
const releasePageUrl = useMemo(() => {
|
||||
@@ -300,102 +316,7 @@ export const SkillDetail: React.FC = () => {
|
||||
<div className="skill-docs bg-clawd-800/50 border border-clawd-700 rounded-xl p-4 sm:p-6 md:p-8 overflow-x-hidden">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold text-white border-b border-clawd-700 pb-3 mb-6 mt-0">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-bold text-white mt-8 mb-4">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-semibold text-white mt-6 mb-3">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-base font-semibold text-white mt-4 mb-2">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-clawd-accent hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside text-gray-300 space-y-2 mb-4 ml-4">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside text-gray-300 space-y-2 mb-4 ml-4">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-gray-300">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-clawd-accent pl-4 py-2 my-4 bg-clawd-900/50 rounded-r text-gray-400 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="text-orange-300 bg-clawd-900 px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="text-gray-200 text-sm font-mono">{children}</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-clawd-900 border border-clawd-700 rounded-lg p-3 sm:p-4 overflow-x-auto mb-4 text-xs sm:text-sm max-w-full">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-6 -mx-4 sm:mx-0 px-4 sm:px-0">
|
||||
<table className="w-full border-collapse text-xs sm:text-sm min-w-[300px]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-clawd-900 border-b border-clawd-600">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-clawd-700/50">{children}</tr>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="text-left px-4 py-3 text-gray-300 font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-3 text-gray-300">{children}</td>
|
||||
),
|
||||
hr: () => <hr className="border-clawd-700 my-6" />,
|
||||
strong: ({ children }) => (
|
||||
<strong className="text-white font-semibold">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="text-gray-200">{children}</em>
|
||||
),
|
||||
}}
|
||||
components={defaultMarkdownComponents}
|
||||
>
|
||||
{stripFrontmatter(doc.content)}
|
||||
</Markdown>
|
||||
|
||||
+52
-5
@@ -4,6 +4,27 @@ import { SkillCard } from '../components/SkillCard';
|
||||
import { Footer } from '../components/Footer';
|
||||
import type { SkillMetadata, SkillsIndex } from '../types';
|
||||
|
||||
const SKILLS_INDEX_PATH = '/skills/index.json';
|
||||
|
||||
const isProbablyHtmlDocument = (text: string): boolean => {
|
||||
const start = text.trimStart().slice(0, 200).toLowerCase();
|
||||
return start.startsWith('<!doctype html') || start.startsWith('<html');
|
||||
};
|
||||
|
||||
const parseSkillsIndex = (raw: string): SkillsIndex | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SkillsIndex> | null;
|
||||
if (!parsed || !Array.isArray(parsed.skills)) return null;
|
||||
return {
|
||||
version: typeof parsed.version === 'string' ? parsed.version : '1.0.0',
|
||||
updated: typeof parsed.updated === 'string' ? parsed.updated : '',
|
||||
skills: parsed.skills as SkillMetadata[],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const SkillsCatalog: React.FC = () => {
|
||||
const [skills, setSkills] = useState<SkillMetadata[]>([]);
|
||||
const [filteredSkills, setFilteredSkills] = useState<SkillMetadata[]>([]);
|
||||
@@ -15,15 +36,41 @@ export const SkillsCatalog: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const fetchSkills = async () => {
|
||||
try {
|
||||
const response = await fetch('./skills/index.json');
|
||||
const response = await fetch(SKILLS_INDEX_PATH, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
// Missing index file is a valid "empty catalog" state.
|
||||
if (response.status === 404) {
|
||||
setSkills([]);
|
||||
setFilteredSkills([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch skills index');
|
||||
}
|
||||
const data: SkillsIndex = await response.json();
|
||||
setSkills(data.skills || []);
|
||||
setFilteredSkills(data.skills || []);
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const raw = await response.text();
|
||||
|
||||
// Some SPA setups return index.html with 200 for missing JSON files.
|
||||
if (!raw.trim() || contentType.includes('text/html') || isProbablyHtmlDocument(raw)) {
|
||||
setSkills([]);
|
||||
setFilteredSkills([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = parseSkillsIndex(raw);
|
||||
if (!data) {
|
||||
throw new Error('Invalid skills index format');
|
||||
}
|
||||
|
||||
setSkills(data.skills);
|
||||
setFilteredSkills(data.skills);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load skills');
|
||||
console.error('Failed to load skills index:', err);
|
||||
setError('Failed to load skills catalog');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { BookOpenText, ExternalLink, FileText } from 'lucide-react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import Markdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { defaultMarkdownComponents } from '../utils/markdownComponents';
|
||||
import {
|
||||
extractTitleFromMarkdown,
|
||||
fallbackTitleFromPath,
|
||||
stripFrontmatter,
|
||||
} from '../utils/markdownHelpers.mjs';
|
||||
import {
|
||||
isWikiIndexSlug,
|
||||
toWikiLlmsPath,
|
||||
toWikiRoute,
|
||||
} from '../utils/wikiPathHelpers.mjs';
|
||||
|
||||
interface WikiDoc {
|
||||
filePath: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const normalizePath = (path: string): string => {
|
||||
const clean = path.replace(/\\/g, '/');
|
||||
const parts: string[] = [];
|
||||
for (const part of clean.split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
if (parts.length > 0) parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
};
|
||||
|
||||
const dirname = (path: string): string => {
|
||||
const idx = path.lastIndexOf('/');
|
||||
return idx === -1 ? '' : path.slice(0, idx);
|
||||
};
|
||||
|
||||
const resolveFromFile = (currentFilePath: string, targetPath: string): string => {
|
||||
if (!targetPath) return currentFilePath;
|
||||
if (targetPath.startsWith('/')) return normalizePath(targetPath.slice(1));
|
||||
const baseDir = dirname(currentFilePath);
|
||||
const joined = baseDir ? `${baseDir}/${targetPath}` : targetPath;
|
||||
return normalizePath(joined);
|
||||
};
|
||||
|
||||
const splitHash = (href: string): { path: string; hash: string } => {
|
||||
const idx = href.indexOf('#');
|
||||
if (idx === -1) return { path: href, hash: '' };
|
||||
return { path: href.slice(0, idx), hash: href.slice(idx) };
|
||||
};
|
||||
|
||||
const toWikiRelativePath = (globPath: string): string =>
|
||||
globPath.replace(/^\.\.\/wiki\//, '').replace(/\\/g, '/');
|
||||
|
||||
const isExternalHref = (href: string): boolean =>
|
||||
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href) || href.startsWith('//');
|
||||
|
||||
const ALLOWED_LINK_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
||||
const ALLOWED_IMAGE_SCHEMES = new Set(['http:', 'https:']);
|
||||
|
||||
const sanitizeHref = (href: string): string | null => {
|
||||
const trimmed = href.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('//')) return null;
|
||||
|
||||
const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/);
|
||||
if (!schemeMatch) return trimmed;
|
||||
|
||||
return ALLOWED_LINK_SCHEMES.has(schemeMatch[1].toLowerCase()) ? trimmed : null;
|
||||
};
|
||||
|
||||
const sanitizeImageSrc = (src: string): string | null => {
|
||||
const trimmed = src.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('//')) return null;
|
||||
|
||||
const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/);
|
||||
if (!schemeMatch) return trimmed;
|
||||
|
||||
return ALLOWED_IMAGE_SCHEMES.has(schemeMatch[1].toLowerCase()) ? trimmed : null;
|
||||
};
|
||||
|
||||
const markdownModules = import.meta.glob('../wiki/**/*.md', {
|
||||
eager: true,
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
}) as Record<string, string>;
|
||||
|
||||
const assetModules = import.meta.glob('../wiki/**/*.{png,jpg,jpeg,gif,svg,webp,avif}', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
}) as Record<string, string>;
|
||||
|
||||
const wikiDocs: WikiDoc[] = Object.entries(markdownModules)
|
||||
.map(([globPath, content]) => {
|
||||
const filePath = toWikiRelativePath(globPath);
|
||||
return {
|
||||
filePath,
|
||||
slug: filePath.replace(/\.md$/i, ''),
|
||||
title: extractTitleFromMarkdown(content, filePath),
|
||||
content: stripFrontmatter(content).trim(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aIndex = a.slug.toLowerCase() === 'index';
|
||||
const bIndex = b.slug.toLowerCase() === 'index';
|
||||
if (aIndex && !bIndex) return -1;
|
||||
if (!aIndex && bIndex) return 1;
|
||||
|
||||
const aModule = a.filePath.startsWith('modules/');
|
||||
const bModule = b.filePath.startsWith('modules/');
|
||||
if (aModule !== bModule) return aModule ? 1 : -1;
|
||||
|
||||
return a.title.localeCompare(b.title, 'en', { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
const wikiDocBySlug = new Map<string, WikiDoc>(
|
||||
wikiDocs.map((doc) => [doc.slug.toLowerCase(), doc]),
|
||||
);
|
||||
|
||||
const wikiDocByFilePath = new Map<string, WikiDoc>(
|
||||
wikiDocs.map((doc) => [doc.filePath.toLowerCase(), doc]),
|
||||
);
|
||||
|
||||
const wikiAssetByPath = new Map<string, string>(
|
||||
Object.entries(assetModules).map(([globPath, assetUrl]) => [
|
||||
toWikiRelativePath(globPath).toLowerCase(),
|
||||
assetUrl,
|
||||
]),
|
||||
);
|
||||
|
||||
const defaultDoc = wikiDocBySlug.get('index') ?? wikiDocs[0] ?? null;
|
||||
|
||||
const toGroupName = (filePath: string): string => {
|
||||
if (!filePath.includes('/')) return 'Core';
|
||||
if (filePath.startsWith('modules/')) return 'Modules';
|
||||
const [firstSegment] = filePath.split('/');
|
||||
return fallbackTitleFromPath(firstSegment);
|
||||
};
|
||||
|
||||
export const WikiBrowser: React.FC = () => {
|
||||
const params = useParams<{ '*': string }>();
|
||||
const wildcard = params['*'] ?? '';
|
||||
const normalizedWildcard = wildcard.replace(/^\/+|\/+$/g, '');
|
||||
let requested = '';
|
||||
let decodeFailed = false;
|
||||
try {
|
||||
requested = decodeURIComponent(normalizedWildcard);
|
||||
} catch (error) {
|
||||
decodeFailed = normalizedWildcard.length > 0;
|
||||
console.warn('Failed to decode wiki route segment', { wildcard, error });
|
||||
requested = '';
|
||||
}
|
||||
const requestedSlug = requested || 'INDEX';
|
||||
|
||||
const selectedDoc = wikiDocBySlug.get(requestedSlug.toLowerCase()) ?? defaultDoc;
|
||||
const notFound =
|
||||
(decodeFailed && normalizedWildcard.length > 0) ||
|
||||
(requested.length > 0 && !wikiDocBySlug.has(requestedSlug.toLowerCase()));
|
||||
|
||||
const groupedDocs = useMemo(() => {
|
||||
const map = new Map<string, WikiDoc[]>();
|
||||
for (const doc of wikiDocs) {
|
||||
const group = toGroupName(doc.filePath);
|
||||
const existing = map.get(group) ?? [];
|
||||
existing.push(doc);
|
||||
map.set(group, existing);
|
||||
}
|
||||
|
||||
const preferredOrder = ['Core', 'Modules'];
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => {
|
||||
const idxA = preferredOrder.indexOf(a);
|
||||
const idxB = preferredOrder.indexOf(b);
|
||||
if (idxA !== -1 || idxB !== -1) {
|
||||
if (idxA === -1) return 1;
|
||||
if (idxB === -1) return -1;
|
||||
return idxA - idxB;
|
||||
}
|
||||
return a.localeCompare(b, 'en', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([name, docs]) => ({
|
||||
name,
|
||||
docs: docs.sort((a, b) =>
|
||||
a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }),
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
if (!selectedDoc) {
|
||||
return (
|
||||
<div className="pt-[52px] py-20 text-center space-y-4">
|
||||
<BookOpenText className="w-12 h-12 text-gray-500 mx-auto" />
|
||||
<h1 className="text-2xl text-white">Wiki unavailable</h1>
|
||||
<p className="text-gray-400">No markdown files were found in the wiki source.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeSlug = selectedDoc.slug.toLowerCase();
|
||||
const pageLlmsPath = toWikiLlmsPath(activeSlug);
|
||||
const showWikiLlmsIndexLink = !isWikiIndexSlug(activeSlug);
|
||||
|
||||
const resolveWikiRouteFromHref = (href: string): string | null => {
|
||||
if (!href || isExternalHref(href) || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
||||
return null;
|
||||
}
|
||||
const { path, hash } = splitHash(href);
|
||||
if (!path || !path.toLowerCase().endsWith('.md')) return null;
|
||||
|
||||
const resolvedFilePath = resolveFromFile(selectedDoc.filePath, path).toLowerCase();
|
||||
const targetDoc = wikiDocByFilePath.get(resolvedFilePath);
|
||||
if (!targetDoc) return null;
|
||||
return `${toWikiRoute(targetDoc.slug)}${hash}`;
|
||||
};
|
||||
|
||||
const resolveAssetUrl = (srcOrHref: string): string | null => {
|
||||
if (!srcOrHref || isExternalHref(srcOrHref) || srcOrHref.startsWith('/')) return null;
|
||||
const { path } = splitHash(srcOrHref);
|
||||
if (!path) return null;
|
||||
const resolvedAssetPath = resolveFromFile(selectedDoc.filePath, path).toLowerCase();
|
||||
return wikiAssetByPath.get(resolvedAssetPath) ?? null;
|
||||
};
|
||||
|
||||
const wikiMarkdownComponents: Components = {
|
||||
...defaultMarkdownComponents,
|
||||
a: ({ href, children }) => {
|
||||
if (!href) return <span className="text-gray-300">{children}</span>;
|
||||
|
||||
const wikiRoute = resolveWikiRouteFromHref(href);
|
||||
if (wikiRoute) {
|
||||
return (
|
||||
<Link to={wikiRoute} className="text-clawd-accent hover:underline">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const assetHref = resolveAssetUrl(href);
|
||||
const finalHref = assetHref ?? href;
|
||||
const safeHref = sanitizeHref(finalHref);
|
||||
if (!safeHref) {
|
||||
return <span className="text-gray-300">{children}</span>;
|
||||
}
|
||||
const external = isExternalHref(safeHref);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className="text-clawd-accent hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) => {
|
||||
const resolvedSrc = src ? resolveAssetUrl(src) : null;
|
||||
const finalSrc = resolvedSrc ?? (src ? sanitizeImageSrc(src) : null);
|
||||
if (!finalSrc) {
|
||||
return <span className="text-gray-500 text-sm">[image blocked]</span>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={finalSrc}
|
||||
alt={alt ?? ''}
|
||||
className="max-w-full h-auto rounded-lg border border-clawd-700 bg-clawd-900/40 p-2 my-4"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-[52px] space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h1 className="text-3xl md:text-4xl text-white flex items-center gap-3">
|
||||
<BookOpenText className="text-clawd-accent" />
|
||||
Wiki
|
||||
</h1>
|
||||
<p className="text-gray-400 max-w-3xl">
|
||||
Full repository wiki rendered from markdown in <code className="text-gray-300">wiki/</code>.
|
||||
This is the same source synced to GitHub Wiki.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href={pageLlmsPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-clawd-700 hover:bg-clawd-600 text-white text-sm transition-colors"
|
||||
>
|
||||
<FileText size={15} />
|
||||
Page llms.txt
|
||||
</a>
|
||||
{showWikiLlmsIndexLink && (
|
||||
<a
|
||||
href="/wiki/llms.txt"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-clawd-800 border border-clawd-700 hover:border-clawd-accent text-white text-sm transition-colors"
|
||||
>
|
||||
<FileText size={15} />
|
||||
Wiki llms.txt Index
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border border-clawd-700 hover:border-clawd-accent text-gray-200 text-sm transition-colors"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
GitHub Wiki
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid lg:grid-cols-[280px_minmax(0,1fr)] gap-6 items-start">
|
||||
<aside className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-4 lg:sticky lg:top-20 max-h-[calc(100vh-7rem)] overflow-auto">
|
||||
<div className="space-y-5">
|
||||
{groupedDocs.map((group) => (
|
||||
<section key={group.name} className="space-y-2">
|
||||
<h2 className="text-xs uppercase tracking-wide text-gray-400">{group.name}</h2>
|
||||
<div className="space-y-1">
|
||||
{group.docs.map((doc) => {
|
||||
const isActive = activeSlug === doc.slug.toLowerCase();
|
||||
return (
|
||||
<Link
|
||||
key={doc.filePath}
|
||||
to={toWikiRoute(doc.slug)}
|
||||
className={`block px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white border border-white/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{doc.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-4 sm:p-6 md:p-8 overflow-x-hidden">
|
||||
{notFound && (
|
||||
<div className="mb-6 p-3 rounded-md border border-orange-800 bg-orange-900/20 text-orange-200 text-sm">
|
||||
Wiki page not found for <code>{requested}</code>. Showing <strong>{selectedDoc.title}</strong> instead.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={wikiMarkdownComponents}
|
||||
>
|
||||
{selectedDoc.content}
|
||||
</Markdown>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 970 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/bin/bash
|
||||
# backfill-exploitability.sh
|
||||
# Adds exploitability scoring to existing advisories in feed.json that don't have it yet.
|
||||
# Historical maintenance utility: normal advisory generation should use
|
||||
# poll-nvd workflow (init/reset when rebuilding) or populate-local-feed.sh.
|
||||
#
|
||||
# Usage: ./scripts/backfill-exploitability.sh [--dry-run] [--feed PATH]
|
||||
# --dry-run Show what would be updated without making changes
|
||||
# --feed PATH Use specified feed file (default: advisories/feed.json)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
# shellcheck source=./feed-utils.sh
|
||||
source "$SCRIPT_DIR/feed-utils.sh"
|
||||
|
||||
# Configuration
|
||||
init_feed_paths "$PROJECT_ROOT"
|
||||
ANALYZER="$PROJECT_ROOT/utils/analyze_exploitability.py"
|
||||
SIGNING_PRIVATE_KEY="${CLAWSEC_FEED_SIGNING_PRIVATE_KEY_PATH:-${CLAWSEC_SIGNING_PRIVATE_KEY_PATH:-$PROJECT_ROOT/clawsec-signing-private.pem}}"
|
||||
SIGNING_PUBLIC_KEY="${CLAWSEC_FEED_SIGNING_PUBLIC_KEY_PATH:-${CLAWSEC_SIGNING_PUBLIC_KEY_PATH:-$PROJECT_ROOT/clawsec-signing-public.pem}}"
|
||||
SIGNING_PASSPHRASE="${CLAWSEC_FEED_SIGNING_PRIVATE_KEY_PASSPHRASE:-${CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE:-}}"
|
||||
|
||||
sign_and_verify_feed_signature() {
|
||||
local feed_file="$1"
|
||||
local signature_file="$2"
|
||||
local tmp_dir
|
||||
local tmp_signature
|
||||
local signature_bin
|
||||
local passin_file
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
tmp_signature="${signature_file}.tmp.$$"
|
||||
signature_bin="$tmp_dir/signature.bin"
|
||||
passin_file="$tmp_dir/passin.txt"
|
||||
|
||||
if [ -n "$SIGNING_PASSPHRASE" ]; then
|
||||
printf '%s' "$SIGNING_PASSPHRASE" > "$passin_file"
|
||||
chmod 600 "$passin_file"
|
||||
|
||||
if ! openssl pkeyutl -sign -rawin -inkey "$SIGNING_PRIVATE_KEY" -passin "file:$passin_file" -in "$feed_file" \
|
||||
| openssl base64 -A > "$tmp_signature"; then
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$tmp_signature"
|
||||
echo "Error: Failed to sign $feed_file" >&2
|
||||
return 1
|
||||
fi
|
||||
elif ! openssl pkeyutl -sign -rawin -inkey "$SIGNING_PRIVATE_KEY" -in "$feed_file" \
|
||||
| openssl base64 -A > "$tmp_signature"; then
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$tmp_signature"
|
||||
echo "Error: Failed to sign $feed_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! openssl base64 -d -A -in "$tmp_signature" -out "$signature_bin"; then
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$tmp_signature"
|
||||
echo "Error: Failed to decode generated signature for $feed_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$SIGNING_PUBLIC_KEY" -sigfile "$signature_bin" -in "$feed_file" >/dev/null; then
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$tmp_signature"
|
||||
echo "Error: Signature verification failed after signing $feed_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
mv "$tmp_signature" "$signature_file"
|
||||
rm -rf "$tmp_dir"
|
||||
echo "✓ Re-signed and verified: $signature_file"
|
||||
}
|
||||
|
||||
# Parse args
|
||||
DRY_RUN=false
|
||||
REQUIRE_SIGNING=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--feed)
|
||||
FEED_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--dry-run] [--feed PATH]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "=== ClawSec Exploitability Backfill ==="
|
||||
echo "Feed path: $FEED_PATH"
|
||||
echo "Dry run: $DRY_RUN"
|
||||
echo ""
|
||||
|
||||
# Verify prerequisites
|
||||
if [ ! -f "$FEED_PATH" ]; then
|
||||
echo "Error: Feed file not found: $FEED_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ANALYZER" ]; then
|
||||
echo "Error: Analyzer script not found: $ANALYZER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Python availability
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "Error: python3 is required but not found in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify analyzer works
|
||||
if ! python3 "$ANALYZER" --help &> /dev/null; then
|
||||
echo "Error: Analyzer script failed to run. Check Python environment."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine whether detached signatures must be regenerated.
|
||||
# Runtime agents that only have public keys should run in dry-run mode.
|
||||
if [ "$DRY_RUN" = "false" ]; then
|
||||
if [ -f "${FEED_PATH}.sig" ]; then
|
||||
REQUIRE_SIGNING=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$REQUIRE_SIGNING" = "true" ]; then
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
echo "Error: openssl is required for detached signature signing/verification"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SIGNING_PRIVATE_KEY" ]; then
|
||||
echo "Error: Signing private key not found: $SIGNING_PRIVATE_KEY"
|
||||
echo "This backfill updates signed feed artifacts. Use --dry-run in public-key-only environments."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SIGNING_PUBLIC_KEY" ]; then
|
||||
echo "Error: Signing public key not found: $SIGNING_PUBLIC_KEY"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create temp directory
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
echo "=== Analyzing Feed ==="
|
||||
|
||||
# Extract advisories without exploitability_score
|
||||
jq '.advisories | map(select(.exploitability_score == null or .exploitability_score == ""))' \
|
||||
"$FEED_PATH" > "$TEMP_DIR/missing_exploitability.json"
|
||||
|
||||
MISSING_COUNT=$(jq 'length' "$TEMP_DIR/missing_exploitability.json")
|
||||
TOTAL_COUNT=$(jq '.advisories | length' "$FEED_PATH")
|
||||
ALREADY_DONE=$((TOTAL_COUNT - MISSING_COUNT))
|
||||
|
||||
echo "Total advisories: $TOTAL_COUNT"
|
||||
echo "Already have exploitability: $ALREADY_DONE"
|
||||
echo "Missing exploitability: $MISSING_COUNT"
|
||||
echo ""
|
||||
|
||||
if [ "$MISSING_COUNT" -eq 0 ]; then
|
||||
echo "✓ All advisories already have exploitability scores!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "=== Dry Run - Would Update These Advisories ==="
|
||||
jq -r '.[] | .id' "$TEMP_DIR/missing_exploitability.json"
|
||||
echo ""
|
||||
echo "Total advisories to update: $MISSING_COUNT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "=== Processing Advisories ==="
|
||||
|
||||
# Process each advisory
|
||||
PROCESSED=0
|
||||
FAILED=0
|
||||
|
||||
# Read original feed to preserve all metadata
|
||||
cp "$FEED_PATH" "$TEMP_DIR/feed_working.json"
|
||||
|
||||
while IFS= read -r advisory; do
|
||||
CVE_ID=$(echo "$advisory" | jq -r '.id')
|
||||
|
||||
echo -n "Processing $CVE_ID... "
|
||||
|
||||
# Prepare input for analyzer
|
||||
ANALYZER_INPUT=$(echo "$advisory" | jq '{
|
||||
cve_id: .id,
|
||||
cvss_score: (.cvss_score // 0.0),
|
||||
type: .type,
|
||||
description: .description,
|
||||
references: (.references // [])
|
||||
}')
|
||||
|
||||
# Run analyzer
|
||||
if ANALYSIS=$(echo "$ANALYZER_INPUT" | python3 "$ANALYZER" --json --check-exploits 2>/dev/null); then
|
||||
# Extract exploitability fields
|
||||
EXPL_SCORE=$(echo "$ANALYSIS" | jq -r '.exploitability_score // "unknown"')
|
||||
EXPL_RATIONALE=$(echo "$ANALYSIS" | jq -r '.exploitability_rationale // "No rationale available"')
|
||||
|
||||
# Update advisory in working feed
|
||||
jq --arg id "$CVE_ID" \
|
||||
--arg score "$EXPL_SCORE" \
|
||||
--arg rationale "$EXPL_RATIONALE" \
|
||||
'(.advisories[] | select(.id == $id)) |= (. + {
|
||||
exploitability_score: $score,
|
||||
exploitability_rationale: $rationale
|
||||
})' "$TEMP_DIR/feed_working.json" > "$TEMP_DIR/feed_updated.json"
|
||||
|
||||
mv "$TEMP_DIR/feed_updated.json" "$TEMP_DIR/feed_working.json"
|
||||
|
||||
echo "✓ $EXPL_SCORE"
|
||||
PROCESSED=$((PROCESSED + 1))
|
||||
else
|
||||
echo "✗ Failed"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done < <(jq -c '.[]' "$TEMP_DIR/missing_exploitability.json")
|
||||
|
||||
# Check if loop executed successfully
|
||||
if [ ! -f "$TEMP_DIR/feed_working.json" ]; then
|
||||
echo "Error: Feed processing failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Processing Complete ==="
|
||||
echo "Processed: $PROCESSED"
|
||||
echo "Failed: $FAILED"
|
||||
echo ""
|
||||
|
||||
# Write updated feed
|
||||
echo "Writing updated feed to: $FEED_PATH"
|
||||
cp "$TEMP_DIR/feed_working.json" "$FEED_PATH"
|
||||
|
||||
# Update feed version and timestamp
|
||||
CURRENT_VERSION=$(jq -r '.version' "$FEED_PATH")
|
||||
UPDATED_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
jq --arg ts "$UPDATED_TS" '.updated = $ts' "$FEED_PATH" > "$TEMP_DIR/feed_final.json"
|
||||
mv "$TEMP_DIR/feed_final.json" "$FEED_PATH"
|
||||
|
||||
echo "✓ Updated feed version: $CURRENT_VERSION"
|
||||
echo "✓ Updated timestamp: $UPDATED_TS"
|
||||
echo ""
|
||||
|
||||
if [ "$REQUIRE_SIGNING" = "true" ]; then
|
||||
echo ""
|
||||
echo "=== Re-signing Advisory Feed ==="
|
||||
|
||||
if [ -f "${FEED_PATH}.sig" ]; then
|
||||
if ! sign_and_verify_feed_signature "$FEED_PATH" "${FEED_PATH}.sig"; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo "✓ Backfill complete!"
|
||||
echo "✓ $PROCESSED advisories updated with exploitability scores"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "⚠ $FAILED advisories failed analysis (kept original data)"
|
||||
fi
|
||||
|
||||
# Verify final state
|
||||
FINAL_MISSING=$(jq '.advisories | map(select(.exploitability_score == null or .exploitability_score == "")) | length' "$FEED_PATH")
|
||||
echo "✓ Advisories still missing exploitability: $FINAL_MISSING"
|
||||
Executable
+263
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ci/enrich_exploitability.sh --mode single|batch --input <path> --output <path> [--cvss-vectors <path>] [--analyzer <path>]
|
||||
|
||||
Options:
|
||||
--mode Processing mode: single advisory object or batch advisory array
|
||||
--input Input JSON path
|
||||
--output Output JSON path
|
||||
--cvss-vectors Optional JSON object mapping advisory id -> CVSS vector
|
||||
--analyzer Optional analyzer path (default: utils/analyze_exploitability.py)
|
||||
--help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
MODE=""
|
||||
INPUT_PATH=""
|
||||
OUTPUT_PATH=""
|
||||
CVSS_VECTORS_PATH=""
|
||||
ANALYZER_PATH="utils/analyze_exploitability.py"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--input)
|
||||
INPUT_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
OUTPUT_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--cvss-vectors)
|
||||
CVSS_VECTORS_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--analyzer)
|
||||
ANALYZER_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$MODE" != "single" && "$MODE" != "batch" ]]; then
|
||||
echo "ERROR: --mode must be one of: single, batch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$INPUT_PATH" || -z "$OUTPUT_PATH" ]]; then
|
||||
echo "ERROR: --input and --output are required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$INPUT_PATH" ]]; then
|
||||
echo "ERROR: input file not found: $INPUT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ANALYZER_PATH" ]]; then
|
||||
echo "ERROR: analyzer file not found: $ANALYZER_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$CVSS_VECTORS_PATH" && ! -f "$CVSS_VECTORS_PATH" ]]; then
|
||||
echo "ERROR: --cvss-vectors file not found: $CVSS_VECTORS_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "ERROR: jq is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python3"
|
||||
else
|
||||
echo "ERROR: python or python3 is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
resolve_cvss_vector() {
|
||||
local advisory_json="$1"
|
||||
local advisory_id
|
||||
advisory_id="$(echo "$advisory_json" | jq -r '.id // ""')"
|
||||
|
||||
if [[ -n "$CVSS_VECTORS_PATH" ]]; then
|
||||
jq -r --arg id "$advisory_id" '.[$id] // ""' "$CVSS_VECTORS_PATH"
|
||||
else
|
||||
echo "$advisory_json" | jq -r '.cvss_vector // ""'
|
||||
fi
|
||||
}
|
||||
|
||||
severity_to_cvss() {
|
||||
case "$1" in
|
||||
critical) echo "9.5" ;;
|
||||
high) echo "7.5" ;;
|
||||
medium) echo "5.5" ;;
|
||||
low) echo "3.0" ;;
|
||||
*) echo "5.0" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_analysis_input() {
|
||||
local advisory_json="$1"
|
||||
local mode="$2"
|
||||
local cve_id cvss_score cvss_vector vuln_type description references severity
|
||||
|
||||
cve_id="$(echo "$advisory_json" | jq -r '.id // ""')"
|
||||
vuln_type="$(echo "$advisory_json" | jq -r '.type // ""')"
|
||||
description="$(echo "$advisory_json" | jq -r '.description // ""')"
|
||||
references="$(echo "$advisory_json" | jq -c '.references // []')"
|
||||
cvss_vector="$(resolve_cvss_vector "$advisory_json")"
|
||||
|
||||
if [[ "$mode" == "single" ]]; then
|
||||
severity="$(echo "$advisory_json" | jq -r '.severity // "medium"')"
|
||||
cvss_score="$(severity_to_cvss "$severity")"
|
||||
else
|
||||
cvss_score="$(echo "$advisory_json" | jq -r '.cvss_score // 0')"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg cve_id "$cve_id" \
|
||||
--argjson cvss_score "$cvss_score" \
|
||||
--arg cvss_vector "$cvss_vector" \
|
||||
--arg type "$vuln_type" \
|
||||
--arg description "$description" \
|
||||
--argjson references "$references" \
|
||||
'{
|
||||
cve_id: $cve_id,
|
||||
cvss_score: $cvss_score,
|
||||
cvss_vector: $cvss_vector,
|
||||
type: $type,
|
||||
description: $description,
|
||||
references: $references
|
||||
}'
|
||||
}
|
||||
|
||||
run_analysis() {
|
||||
local advisory_json="$1"
|
||||
local mode="$2"
|
||||
local output_file="$3"
|
||||
local advisory_id analysis_input analysis
|
||||
|
||||
advisory_id="$(echo "$advisory_json" | jq -r '.id // "unknown"')"
|
||||
analysis_input="$(build_analysis_input "$advisory_json" "$mode")"
|
||||
|
||||
if analysis="$(echo "$analysis_input" | "$PYTHON_BIN" "$ANALYZER_PATH" --json --check-exploits 2>/dev/null)"; then
|
||||
echo "$analysis" > "$output_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "::warning::Failed to analyze exploitability for $advisory_id, continuing without enrichment"
|
||||
return 1
|
||||
}
|
||||
|
||||
enrich_single() {
|
||||
if ! jq -e 'type == "object"' "$INPUT_PATH" >/dev/null; then
|
||||
echo "ERROR: single mode expects JSON object at $INPUT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local advisory analysis_file output_tmp
|
||||
advisory="$(cat "$INPUT_PATH")"
|
||||
analysis_file="$tmpdir/analysis_single.json"
|
||||
output_tmp="$tmpdir/output_single.json"
|
||||
|
||||
if run_analysis "$advisory" "single" "$analysis_file"; then
|
||||
jq --slurpfile analysis "$analysis_file" '
|
||||
. + {
|
||||
exploitability_score: $analysis[0].exploitability_score,
|
||||
exploitability_rationale: $analysis[0].exploitability_rationale,
|
||||
attack_vector_analysis: $analysis[0].attack_vector_analysis,
|
||||
exploit_detection: $analysis[0].exploit_detection
|
||||
}
|
||||
' "$INPUT_PATH" > "$output_tmp"
|
||||
else
|
||||
cp "$INPUT_PATH" "$output_tmp"
|
||||
fi
|
||||
|
||||
mv "$output_tmp" "$OUTPUT_PATH"
|
||||
echo "Exploitability enrichment complete (single): $OUTPUT_PATH"
|
||||
}
|
||||
|
||||
enrich_batch() {
|
||||
if ! jq -e 'type == "array"' "$INPUT_PATH" >/dev/null; then
|
||||
echo "ERROR: batch mode expects JSON array at $INPUT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local analyzed_count failed_count index advisory analysis_file output_tmp analyses_json
|
||||
analyzed_count=0
|
||||
failed_count=0
|
||||
index=0
|
||||
analyses_json="$tmpdir/analyses.json"
|
||||
output_tmp="$tmpdir/output_batch.json"
|
||||
|
||||
while IFS= read -r advisory; do
|
||||
analysis_file="$tmpdir/analysis_${index}.json"
|
||||
if run_analysis "$advisory" "batch" "$analysis_file"; then
|
||||
analyzed_count=$((analyzed_count + 1))
|
||||
else
|
||||
failed_count=$((failed_count + 1))
|
||||
rm -f "$analysis_file"
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done < <(jq -c '.[]' "$INPUT_PATH")
|
||||
|
||||
if ls "$tmpdir"/analysis_*.json >/dev/null 2>&1; then
|
||||
jq -s '.' "$tmpdir"/analysis_*.json > "$analyses_json"
|
||||
else
|
||||
echo '[]' > "$analyses_json"
|
||||
fi
|
||||
|
||||
jq --slurpfile analyses "$analyses_json" '
|
||||
map(
|
||||
. as $advisory |
|
||||
($analyses[0] | map(select(.cve_id == $advisory.id)) | first) as $analysis |
|
||||
if $analysis then
|
||||
$advisory + {
|
||||
exploitability_score: $analysis.exploitability_score,
|
||||
exploitability_rationale: $analysis.exploitability_rationale,
|
||||
attack_vector_analysis: $analysis.attack_vector_analysis,
|
||||
exploit_detection: $analysis.exploit_detection
|
||||
}
|
||||
else
|
||||
$advisory
|
||||
end
|
||||
)
|
||||
' "$INPUT_PATH" > "$output_tmp"
|
||||
|
||||
mv "$output_tmp" "$OUTPUT_PATH"
|
||||
echo "Exploitability enrichment complete (batch): $OUTPUT_PATH"
|
||||
echo "Analyzed: $analyzed_count, failed: $failed_count"
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "single" ]]; then
|
||||
enrich_single
|
||||
else
|
||||
enrich_batch
|
||||
fi
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SKILL_MD="skills/clawsec-suite/SKILL.md"
|
||||
CANONICAL_KEYS=(
|
||||
"clawsec-signing-public.pem"
|
||||
"advisories/feed-signing-public.pem"
|
||||
"skills/clawsec-suite/advisories/feed-signing-public.pem"
|
||||
)
|
||||
|
||||
fingerprint_for_pem() {
|
||||
local pem_file="$1"
|
||||
openssl pkey -pubin -in "$pem_file" -outform DER | shasum -a 256 | awk '{print $1}'
|
||||
}
|
||||
|
||||
if [[ ! -f "$SKILL_MD" ]]; then
|
||||
echo "ERROR: missing $SKILL_MD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOC_EXPECTED_FPR="$(awk -F'"' '/RELEASE_PUBKEY_SHA256=/{print $2; exit}' "$SKILL_MD")"
|
||||
if [[ -z "$DOC_EXPECTED_FPR" ]]; then
|
||||
echo "ERROR: could not parse RELEASE_PUBKEY_SHA256 from $SKILL_MD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DOC_KEY="$(mktemp)"
|
||||
trap 'rm -f "$TMP_DOC_KEY"' EXIT
|
||||
awk '
|
||||
/-----BEGIN PUBLIC KEY-----/ {in_key=1}
|
||||
in_key {print}
|
||||
/-----END PUBLIC KEY-----/ {exit}
|
||||
' "$SKILL_MD" > "$TMP_DOC_KEY"
|
||||
|
||||
if ! grep -q "BEGIN PUBLIC KEY" "$TMP_DOC_KEY"; then
|
||||
echo "ERROR: could not extract inline public key from $SKILL_MD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOC_INLINE_FPR="$(fingerprint_for_pem "$TMP_DOC_KEY")"
|
||||
|
||||
if [[ "$DOC_INLINE_FPR" != "$DOC_EXPECTED_FPR" ]]; then
|
||||
echo "ERROR: SKILL.md mismatch: inline key fingerprint ($DOC_INLINE_FPR) != RELEASE_PUBKEY_SHA256 ($DOC_EXPECTED_FPR)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "SKILL.md inline key fingerprint matches RELEASE_PUBKEY_SHA256: $DOC_EXPECTED_FPR"
|
||||
|
||||
CANONICAL_FPR=""
|
||||
for key_file in "${CANONICAL_KEYS[@]}"; do
|
||||
if [[ ! -f "$key_file" ]]; then
|
||||
echo "ERROR: missing canonical key file: $key_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
fpr="$(fingerprint_for_pem "$key_file")"
|
||||
echo "$key_file -> $fpr"
|
||||
if [[ -z "$CANONICAL_FPR" ]]; then
|
||||
CANONICAL_FPR="$fpr"
|
||||
elif [[ "$fpr" != "$CANONICAL_FPR" ]]; then
|
||||
echo "ERROR: key fingerprint mismatch among canonical pem files" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$CANONICAL_FPR" != "$DOC_EXPECTED_FPR" ]]; then
|
||||
echo "ERROR: canonical pem fingerprint ($CANONICAL_FPR) != SKILL.md RELEASE_PUBKEY_SHA256 ($DOC_EXPECTED_FPR)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All signing key references are consistent: $CANONICAL_FPR"
|
||||
@@ -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();
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Sandbox regression test for hermes-attestation-guardian using an isolated Docker Hermes instance.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/hermes_attestation_sandbox_regression.sh
|
||||
#
|
||||
# Optional env overrides:
|
||||
# IMAGE=python:3.11-slim
|
||||
# HERMES_AGENT_SRC=/home/davida/.hermes/hermes-agent
|
||||
# SKILL_SRC=/home/davida/clawsec/skills/hermes-attestation-guardian
|
||||
# WELL_KNOWN_PORT=8765
|
||||
|
||||
IMAGE="${IMAGE:-python:3.11-slim}"
|
||||
HERMES_AGENT_SRC="${HERMES_AGENT_SRC:-$HOME/.hermes/hermes-agent}"
|
||||
SKILL_SRC="${SKILL_SRC:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/skills/hermes-attestation-guardian}"
|
||||
WELL_KNOWN_PORT="${WELL_KNOWN_PORT:-8765}"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "ERROR: docker is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "$HERMES_AGENT_SRC" ]]; then
|
||||
echo "ERROR: HERMES_AGENT_SRC not found: $HERMES_AGENT_SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "$SKILL_SRC" ]]; then
|
||||
echo "ERROR: SKILL_SRC not found: $SKILL_SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[sandbox] image=$IMAGE"
|
||||
echo "[sandbox] hermes-agent-src=$HERMES_AGENT_SRC"
|
||||
echo "[sandbox] skill-src=$SKILL_SRC"
|
||||
|
||||
docker run --rm \
|
||||
-e HOME=/tmp/hermes-sandbox-home \
|
||||
-e HERMES_HOME=/tmp/hermes-sandbox-home \
|
||||
-v "$HERMES_AGENT_SRC":/opt/hermes-agent:ro \
|
||||
-v "$SKILL_SRC":/opt/skill-src:ro \
|
||||
"$IMAGE" bash -lc "
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y --no-install-recommends openssl ca-certificates curl nodejs npm >/dev/null
|
||||
|
||||
cp -a /opt/hermes-agent /tmp/hermes-agent-src
|
||||
python -m pip install --no-cache-dir /tmp/hermes-agent-src >/tmp/pip-install.log 2>&1
|
||||
mkdir -p \"\$HOME\"
|
||||
|
||||
echo \"INSIDE_HOME=\$HOME\"
|
||||
echo \"INSIDE_HERMES_HOME=\$HERMES_HOME\"
|
||||
|
||||
mkdir -p /tmp/well/.well-known/skills/hermes-attestation-guardian
|
||||
cp -a /opt/skill-src/. /tmp/well/.well-known/skills/hermes-attestation-guardian/
|
||||
python3 - <<'PY'
|
||||
import os,json
|
||||
root='/tmp/well/.well-known/skills'
|
||||
sk='hermes-attestation-guardian'
|
||||
base=os.path.join(root,sk)
|
||||
files=[]
|
||||
for dp,_,fns in os.walk(base):
|
||||
for fn in fns:
|
||||
files.append(os.path.relpath(os.path.join(dp,fn),base).replace('\\\\','/'))
|
||||
idx={'generated_at':'2026-04-16T00:00:00Z','skills':[{'name':sk,'version':'0.0.1','description':'sandbox feature test','path':f'.well-known/skills/{sk}','files':sorted(files)}]}
|
||||
with open(os.path.join(root,'index.json'),'w') as f: json.dump(idx,f)
|
||||
PY
|
||||
python3 -m http.server $WELL_KNOWN_PORT --directory /tmp/well >/tmp/http.log 2>&1 &
|
||||
HPID=\$!
|
||||
sleep 1
|
||||
|
||||
INSTALL_OUT=\$(hermes skills install \"well-known:http://127.0.0.1:$WELL_KNOWN_PORT/.well-known/skills/hermes-attestation-guardian\" --yes 2>&1)
|
||||
echo \"\$INSTALL_OUT\"
|
||||
|
||||
echo \"\$INSTALL_OUT\" | grep -q \"Verdict: SAFE\"
|
||||
echo \"\$INSTALL_OUT\" | grep -q \"Decision: ALLOWED\"
|
||||
|
||||
SKILL_DIR=\"\$HERMES_HOME/skills/hermes-attestation-guardian\"
|
||||
mkdir -p \"\$HERMES_HOME/security/attestations\"
|
||||
echo \"alpha\" > /tmp/watch.txt
|
||||
echo \"anchor-v1\" > /tmp/anchor.pem
|
||||
cat > /tmp/policy.json <<EOF
|
||||
{\"watch_files\": [\"/tmp/watch.txt\"], \"trust_anchor_files\": [\"/tmp/anchor.pem\"]}
|
||||
EOF
|
||||
|
||||
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:00:00.000Z --write-sha256 >/tmp/generate.log
|
||||
DIGEST=\$(cut -d\" \" -f1 \"\$HERMES_HOME/security/attestations/current.json.sha256\")
|
||||
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --expected-sha256 \"\$DIGEST\" >/tmp/verify-ok.log
|
||||
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/sign.key >/dev/null 2>&1
|
||||
openssl pkey -in /tmp/sign.key -pubout -out /tmp/sign.pub.pem >/dev/null 2>&1
|
||||
openssl dgst -sha256 -sign /tmp/sign.key -out /tmp/current.sig \"\$HERMES_HOME/security/attestations/current.json\"
|
||||
node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --signature /tmp/current.sig --public-key /tmp/sign.pub.pem >/tmp/verify-sig.log
|
||||
|
||||
cp \"\$HERMES_HOME/security/attestations/current.json\" \"\$HERMES_HOME/security/attestations/baseline.json\"
|
||||
BASE_SHA=\$(sha256sum \"\$HERMES_HOME/security/attestations/baseline.json\" | cut -d\" \" -f1)
|
||||
echo \"beta\" > /tmp/watch.txt
|
||||
echo \"anchor-v2\" > /tmp/anchor.pem
|
||||
node \"\$SKILL_DIR/scripts/generate_attestation.mjs\" --output \"\$HERMES_HOME/security/attestations/current.json\" --policy /tmp/policy.json --generated-at 2026-04-16T00:10:00.000Z >/tmp/generate-drift.log
|
||||
set +e
|
||||
DRIFT_OUT=\$(node \"\$SKILL_DIR/scripts/verify_attestation.mjs\" --input \"\$HERMES_HOME/security/attestations/current.json\" --baseline \"\$HERMES_HOME/security/attestations/baseline.json\" --baseline-expected-sha256 \"\$BASE_SHA\" --fail-on-severity critical 2>&1)
|
||||
DRIFT_CODE=\$?
|
||||
set -e
|
||||
[ \"\$DRIFT_CODE\" -ne 0 ]
|
||||
echo \"\$DRIFT_OUT\" | grep -Eq \"WATCHED_FILE_DRIFT|TRUST_ANCHOR_MISMATCH\"
|
||||
|
||||
node \"\$SKILL_DIR/scripts/setup_attestation_cron.mjs\" --every 6h --print-only > /tmp/cron-preview.log
|
||||
grep -q \"Preflight review:\" /tmp/cron-preview.log
|
||||
grep -q \"# >>> hermes-attestation-guardian >>>\" /tmp/cron-preview.log
|
||||
|
||||
echo \"=== SANDBOX FEATURE TEST SUMMARY ===\"
|
||||
echo \"install_safe_allowed=PASS\"
|
||||
echo \"generate_with_policy=PASS\"
|
||||
echo \"verify_expected_sha=PASS\"
|
||||
echo \"verify_signature=PASS\"
|
||||
echo \"baseline_drift_fail_closed=PASS\"
|
||||
echo \"scheduler_preview=PASS\"
|
||||
|
||||
kill \$HPID >/dev/null 2>&1 || true
|
||||
wait \$HPID 2>/dev/null || true
|
||||
"
|
||||
|
||||
echo "[sandbox] completed successfully"
|
||||
+166
-27
@@ -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(
|
||||
@@ -141,8 +148,11 @@ jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_REF_PATTERN" '
|
||||
FILTERED=$(jq 'length' "$TEMP_DIR/filtered_cves.json")
|
||||
echo "Filtered CVEs (matching criteria): $FILTERED"
|
||||
|
||||
# Get existing advisory IDs
|
||||
if [ -f "$FEED_PATH" ]; then
|
||||
# Get existing advisory IDs (unless force mode)
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
echo "Force mode: ignoring existing advisory IDs during transform"
|
||||
EXISTING_IDS=""
|
||||
elif [ -f "$FEED_PATH" ]; then
|
||||
EXISTING_IDS=$(jq -r '.advisories[]?.id // empty' "$FEED_PATH" | sort -u)
|
||||
else
|
||||
EXISTING_IDS=""
|
||||
@@ -165,21 +175,122 @@ jq --argjson existing "$EXISTING_JSON" '
|
||||
.cve.metrics.cvssMetricV30[0]?.cvssData.baseScore //
|
||||
.cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
|
||||
null;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
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,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: "vulnerable_skill",
|
||||
type: nvd_category_name,
|
||||
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"
|
||||
@@ -194,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"
|
||||
@@ -207,13 +340,26 @@ NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
if [ -f "$FEED_PATH" ]; then
|
||||
jq --argjson new "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '
|
||||
.updated = $now |
|
||||
.advisories = (.advisories + $new | sort_by(.published) | reverse)
|
||||
# Merge by advisory ID so force mode can refresh existing CVEs without duplicates
|
||||
.advisories = (
|
||||
reduce (.advisories + $new)[] as $adv
|
||||
({};
|
||||
if ($adv.id // "") == "" then
|
||||
.
|
||||
else
|
||||
.[$adv.id] = $adv
|
||||
end
|
||||
)
|
||||
| [.[]]
|
||||
| sort_by(.published)
|
||||
| reverse
|
||||
)
|
||||
' "$FEED_PATH" > "$TEMP_DIR/updated_feed.json"
|
||||
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
|
||||
@@ -223,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")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# populate-local-skills.sh
|
||||
# Builds local skills index from skills/ directory for development preview.
|
||||
# This mirrors the skill-release.yml pipeline exactly - generates real checksums and .skill packages.
|
||||
# This mirrors the skill-release.yml pipeline exactly - generates real checksums.
|
||||
#
|
||||
# Usage: ./scripts/populate-local-skills.sh
|
||||
|
||||
@@ -159,50 +159,6 @@ FILEENTRY
|
||||
}
|
||||
SKILLJSON
|
||||
|
||||
# === Create .skill package BEFORE closing checksums JSON ===
|
||||
SKILL_PACKAGE="$PUBLIC_SKILLS_DIR/$SKILL_NAME/${SKILL_NAME}.skill"
|
||||
|
||||
# Get files from SBOM and create zip
|
||||
pushd "$SKILL_DIR" > /dev/null
|
||||
|
||||
FILES=$(jq -r '.sbom.files[].path' skill.json 2>/dev/null | tr '\n' ' ')
|
||||
|
||||
if [ -n "$FILES" ]; then
|
||||
# Create zip with SBOM files + skill.json
|
||||
zip -r "$SKILL_PACKAGE" $FILES skill.json 2>/dev/null || true
|
||||
|
||||
# Add README if exists
|
||||
if [ -f README.md ]; then
|
||||
zip -u "$SKILL_PACKAGE" README.md 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -f "$SKILL_PACKAGE" ]; then
|
||||
PACKAGE_SIZE=$(stat -f%z "$SKILL_PACKAGE" 2>/dev/null || stat -c%s "$SKILL_PACKAGE")
|
||||
echo " ✓ Created: ${SKILL_NAME}.skill ($(( PACKAGE_SIZE / 1024 ))KB)"
|
||||
|
||||
# Add .skill package checksum
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
SKILL_PACKAGE_SHA=$(sha256sum "$SKILL_PACKAGE" | awk '{print $1}')
|
||||
else
|
||||
SKILL_PACKAGE_SHA=$(shasum -a 256 "$SKILL_PACKAGE" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
echo "," >> "$CHECKSUMS_FILE"
|
||||
cat >> "$CHECKSUMS_FILE" << SKILLPACKAGE
|
||||
"${SKILL_NAME}.skill": {
|
||||
"sha256": "$SKILL_PACKAGE_SHA",
|
||||
"size": $PACKAGE_SIZE,
|
||||
"url": "https://clawsec.prompt.security/releases/download/$TAG/${SKILL_NAME}.skill"
|
||||
}
|
||||
SKILLPACKAGE
|
||||
echo " ✓ Checksum: ${SKILL_NAME}.skill ($SKILL_PACKAGE_SHA)"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ No SBOM files, skipping .skill package"
|
||||
fi
|
||||
|
||||
popd > /dev/null
|
||||
|
||||
# Close checksums JSON
|
||||
cat >> "$CHECKSUMS_FILE" << EOF
|
||||
}
|
||||
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# populate-local-wiki.sh
|
||||
# Generates wiki-derived public assets for local preview and CI parity.
|
||||
#
|
||||
# Usage: ./scripts/populate-local-wiki.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
WIKI_DIR="$PROJECT_ROOT/wiki"
|
||||
PUBLIC_WIKI_DIR="$PROJECT_ROOT/public/wiki"
|
||||
|
||||
if [ ! -d "$WIKI_DIR" ]; then
|
||||
echo "Error: wiki directory not found at $WIKI_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ClawSec Local Wiki Populator ==="
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
|
||||
node "$PROJECT_ROOT/scripts/generate-wiki-llms.mjs"
|
||||
|
||||
PAGE_COUNT=0
|
||||
if [ -d "$PUBLIC_WIKI_DIR" ]; then
|
||||
PAGE_COUNT=$(find "$PUBLIC_WIKI_DIR" -type f -path '*/llms.txt' ! -path "$PUBLIC_WIKI_DIR/llms.txt" | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
echo "Wiki llms index: $PUBLIC_WIKI_DIR/llms.txt"
|
||||
echo "Wiki llms pages: $PAGE_COUNT files under $PUBLIC_WIKI_DIR/<page>/llms.txt"
|
||||
@@ -76,13 +76,13 @@ fi
|
||||
# ESLint
|
||||
echo -e "\n${YELLOW}Running ESLint...${NC}"
|
||||
if $FIX_MODE; then
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --fix; then
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --ignore-pattern '.auto-claude/**' --fix; then
|
||||
check_pass "ESLint (with auto-fix)"
|
||||
else
|
||||
check_fail "ESLint found unfixable issues"
|
||||
fi
|
||||
else
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0; then
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --ignore-pattern '.auto-claude/**' --max-warnings 0; then
|
||||
check_pass "ESLint"
|
||||
else
|
||||
check_fail "ESLint found issues (run with --fix to auto-fix)"
|
||||
@@ -190,7 +190,7 @@ print_header "Security"
|
||||
# Trivy FS Scan
|
||||
if command -v trivy &> /dev/null; then
|
||||
echo -e "\n${YELLOW}Running Trivy filesystem scan...${NC}"
|
||||
if trivy fs . --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed; then
|
||||
if trivy fs . --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed --skip-dirs .auto-claude --skip-files clawsec-signing-private.pem; then
|
||||
check_pass "Trivy filesystem scan"
|
||||
else
|
||||
check_fail "Trivy found CRITICAL/HIGH vulnerabilities"
|
||||
|
||||
+175
-54
@@ -1,28 +1,62 @@
|
||||
#!/bin/bash
|
||||
# Usage: ./scripts/release-skill.sh <skill-name> <version>
|
||||
# Usage: ./scripts/release-skill.sh <skill-name> <version> [--force-tag]
|
||||
# Example: ./scripts/release-skill.sh clawsec-feed 1.1.0
|
||||
#
|
||||
# This script ensures version consistency by:
|
||||
# 1. Updating skill.json with the new version
|
||||
# 2. Updating any hardcoded version URLs in skill.json and SKILL.md
|
||||
# 3. Committing the changes
|
||||
# 4. Creating the git tag
|
||||
# 4. Creating the git tag (only on main/master branch)
|
||||
#
|
||||
# After running, push with: git push && git push origin <tag>
|
||||
# Branch-aware workflow:
|
||||
# Feature branch: Updates versions, commits, pushes → CI validates build
|
||||
# Main branch: Updates versions, commits, creates tag → push triggers release
|
||||
#
|
||||
# Use --force-tag to create a tag even when not on main/master.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_NAME="$1"
|
||||
VERSION="$2"
|
||||
SKILL_PATH="skills/$SKILL_NAME"
|
||||
# Parse arguments
|
||||
FORCE_TAG=false
|
||||
POSITIONAL_ARGS=()
|
||||
|
||||
# Validation
|
||||
if [ -z "$SKILL_NAME" ] || [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <skill-name> <version>"
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--force-tag)
|
||||
FORCE_TAG=true
|
||||
;;
|
||||
*)
|
||||
POSITIONAL_ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${#POSITIONAL_ARGS[@]}" -ne 2 ]; then
|
||||
echo "Usage: $0 <skill-name> <version> [--force-tag]"
|
||||
echo "Example: $0 clawsec-feed 1.1.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILL_NAME="${POSITIONAL_ARGS[0]}"
|
||||
VERSION="${POSITIONAL_ARGS[1]}"
|
||||
SKILL_PATH="skills/$SKILL_NAME"
|
||||
|
||||
# Initialize variables
|
||||
RELEASE_NOTES=""
|
||||
|
||||
# Ensure we're on a branch (not detached HEAD) so release flow works from feature branches
|
||||
CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)"
|
||||
if [ -z "$CURRENT_BRANCH" ]; then
|
||||
echo "Error: Detached HEAD detected. Checkout a branch before running release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine if we're on a release branch (main/master)
|
||||
IS_RELEASE_BRANCH=false
|
||||
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
|
||||
IS_RELEASE_BRANCH=true
|
||||
fi
|
||||
|
||||
# Security: Validate skill name to prevent path injection
|
||||
# Only allow lowercase alphanumeric characters and hyphens
|
||||
if ! [[ "$SKILL_NAME" =~ ^[a-z0-9-]+$ ]]; then
|
||||
@@ -44,12 +78,6 @@ fi
|
||||
|
||||
TAG="${SKILL_NAME}-v${VERSION}"
|
||||
|
||||
# Check if tag already exists
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: Tag $TAG already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes in skill directory
|
||||
if ! git diff --quiet "$SKILL_PATH/" 2>/dev/null; then
|
||||
echo "Error: $SKILL_PATH/ has uncommitted changes. Please commit or stash them first."
|
||||
@@ -57,6 +85,12 @@ if ! git diff --quiet "$SKILL_PATH/" 2>/dev/null; then
|
||||
fi
|
||||
|
||||
echo "Releasing $SKILL_NAME version $VERSION"
|
||||
echo "Branch: $CURRENT_BRANCH"
|
||||
if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then
|
||||
echo "Mode: Full release (will create tag)"
|
||||
else
|
||||
echo "Mode: Prep only (tag deferred until merge to main)"
|
||||
fi
|
||||
echo "======================================="
|
||||
|
||||
# Create a temporary directory for atomic operations
|
||||
@@ -174,48 +208,135 @@ for file in "${FILES_TO_STAGE[@]}"; do
|
||||
done
|
||||
|
||||
# Verify staged changes before committing
|
||||
MADE_COMMIT=false
|
||||
if git diff --cached --quiet; then
|
||||
echo "Warning: No changes to commit"
|
||||
exit 0
|
||||
echo "Note: Version already at $VERSION — no changes to commit"
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
else
|
||||
# Commit the version bump
|
||||
echo "Committing changes..."
|
||||
if ! git commit -m "chore($SKILL_NAME): bump version to $VERSION"; then
|
||||
echo "Error: Failed to commit changes"
|
||||
exit 1
|
||||
fi
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
MADE_COMMIT=true
|
||||
fi
|
||||
|
||||
# Commit the version bump
|
||||
echo "Committing changes..."
|
||||
if ! git commit -m "chore($SKILL_NAME): bump version to $VERSION"; then
|
||||
echo "Error: Failed to commit changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Save commit SHA for recovery (in case tag creation fails)
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
# Save commit SHA for recovery
|
||||
echo "Committed: $COMMIT_SHA"
|
||||
|
||||
# Create annotated tag
|
||||
echo "Creating tag: $TAG"
|
||||
if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then
|
||||
echo "Error: Failed to create tag $TAG" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has been created but NOT tagged:" >&2
|
||||
echo " Commit: $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo "Recovery options:" >&2
|
||||
echo " 1. Fix the issue and tag manually:" >&2
|
||||
echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo " 2. Investigate why tagging failed:" >&2
|
||||
echo " - Check if tag exists: git tag -l '$TAG'" >&2
|
||||
echo " - Check permissions: ls -ld .git/refs/tags" >&2
|
||||
echo "" >&2
|
||||
echo " 3. To rollback the commit (if desired):" >&2
|
||||
echo " git reset --hard HEAD~1" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has NOT been pushed. Fix the issue before pushing." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Create tag only on release branches (or if forced)
|
||||
if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then
|
||||
# Check if tag already exists (only matters when we're creating one)
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: Tag $TAG already exists"
|
||||
if [[ "$MADE_COMMIT" == "true" ]]; then
|
||||
echo "Rolling back version-bump commit..."
|
||||
git reset --hard HEAD~1
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! To release, push the commit and tag:"
|
||||
echo " git push && git push origin $TAG"
|
||||
echo ""
|
||||
echo "Or to undo:"
|
||||
echo " git reset --hard HEAD~1 && git tag -d $TAG"
|
||||
echo "Creating tag: $TAG"
|
||||
if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then
|
||||
echo "Error: Failed to create tag $TAG" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has been created but NOT tagged:" >&2
|
||||
echo " Commit: $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo "Recovery options:" >&2
|
||||
echo " 1. Fix the issue and tag manually:" >&2
|
||||
echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo " 2. Investigate why tagging failed:" >&2
|
||||
echo " - Check if tag exists: git tag -l '$TAG'" >&2
|
||||
echo " - Check permissions: ls -ld .git/refs/tags" >&2
|
||||
echo "" >&2
|
||||
echo " 3. To rollback the commit (if desired):" >&2
|
||||
echo " git reset --hard HEAD~1" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has NOT been pushed. Fix the issue before pushing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract changelog entry for this version and create GitHub release
|
||||
RELEASE_NOTES=""
|
||||
GH_RELEASE_CREATED=false
|
||||
if [ -f "$SKILL_PATH/CHANGELOG.md" ]; then
|
||||
echo "Extracting changelog entry for version $VERSION..."
|
||||
|
||||
# Extract the changelog section for this version
|
||||
# Pattern: ## [VERSION] - DATE ... until next ## [ or end of file
|
||||
RELEASE_NOTES=$(awk -v version="$VERSION" '
|
||||
BEGIN { in_section = 0; found = 0 }
|
||||
$0 ~ ("^## \\[" version "\\]") { in_section = 1; found = 1; next }
|
||||
in_section && /^## \[/ && found { exit }
|
||||
in_section { print }
|
||||
' "$SKILL_PATH/CHANGELOG.md" | sed '/^$/d' | sed '1{/^$/d;}')
|
||||
|
||||
if [ -n "$RELEASE_NOTES" ]; then
|
||||
echo "Found changelog entry with $(echo "$RELEASE_NOTES" | wc -l) lines"
|
||||
|
||||
# Create GitHub release with changelog notes
|
||||
echo "Creating GitHub release with changelog notes..."
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
if ! echo "$RELEASE_NOTES" | gh release create "$TAG" \
|
||||
--title "$SKILL_NAME v$VERSION" \
|
||||
--notes-file -; then
|
||||
echo "Warning: Failed to create GitHub release, but tag was created successfully" >&2
|
||||
echo "You can manually create the release at: https://github.com/$(git remote get-url origin | sed 's/.*github.com[:/]\([^.]*\).*/\1/')/releases/new" >&2
|
||||
else
|
||||
echo "✓ GitHub release created with changelog notes"
|
||||
GH_RELEASE_CREATED=true
|
||||
fi
|
||||
else
|
||||
echo "Warning: GitHub CLI (gh) not found. Skipping automatic release creation." >&2
|
||||
echo "Install GitHub CLI and run manually:" >&2
|
||||
echo " gh release create '$TAG' --title '$SKILL_NAME v$VERSION' --notes-file <(echo \"$RELEASE_NOTES\")" >&2
|
||||
fi
|
||||
else
|
||||
echo "Warning: No changelog entry found for version $VERSION" >&2
|
||||
fi
|
||||
else
|
||||
echo "No CHANGELOG.md found in $SKILL_PATH - skipping release notes"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! To release, push the tag:"
|
||||
if [[ "$MADE_COMMIT" == "true" ]]; then
|
||||
echo " git push origin $CURRENT_BRANCH"
|
||||
fi
|
||||
echo " git push origin $TAG"
|
||||
echo ""
|
||||
echo "Or to undo:"
|
||||
if [[ "$MADE_COMMIT" == "true" ]]; then
|
||||
echo " git reset --hard HEAD~1 && git tag -d $TAG"
|
||||
else
|
||||
echo " git tag -d $TAG"
|
||||
fi
|
||||
if [[ "$GH_RELEASE_CREATED" == "true" ]]; then
|
||||
echo ""
|
||||
echo "Note: GitHub release was created automatically with changelog notes."
|
||||
fi
|
||||
else
|
||||
# Feature branch: skip tagging, instruct user on next steps
|
||||
echo ""
|
||||
echo "Done! Version updated and committed (tag deferred)."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Push your branch for CI validation:"
|
||||
echo " git push origin $CURRENT_BRANCH"
|
||||
echo ""
|
||||
echo " 2. After CI passes and PR is merged to main, create the tag and release:"
|
||||
echo " git checkout main && git pull"
|
||||
echo " git tag -a '$TAG' $COMMIT_SHA -m '$SKILL_NAME version $VERSION'"
|
||||
echo " git push origin $TAG"
|
||||
if [ -f "$SKILL_PATH/CHANGELOG.md" ]; then
|
||||
echo " # Create GitHub release with changelog (requires GitHub CLI):"
|
||||
echo " gh release create '$TAG' --title '$SKILL_NAME v$VERSION' --generate-notes"
|
||||
fi
|
||||
echo ""
|
||||
echo "Or to undo the version bump:"
|
||||
echo " git reset --hard HEAD~1"
|
||||
fi
|
||||
|
||||
@@ -45,8 +45,7 @@ get_release_assets() {
|
||||
# Always included
|
||||
assets+=("skill.json")
|
||||
assets+=("checksums.json")
|
||||
assets+=("${skill_name}.skill")
|
||||
|
||||
|
||||
# README if exists
|
||||
if [ -f "$skill_path/README.md" ]; then
|
||||
assets+=("README.md")
|
||||
@@ -151,12 +150,6 @@ validate_skill() {
|
||||
fi
|
||||
done < <(extract_all_referenced_files "$skill_path/SKILL.md")
|
||||
|
||||
# Check for common patterns that reference this skill
|
||||
if grep -qE "/${skill_name}\.skill" "$skill_path/SKILL.md"; then
|
||||
if printf '%s\n' "${RELEASE_ASSETS[@]}" | grep -q "^${skill_name}.skill$"; then
|
||||
echo -e " ${GREEN}✓${NC} ${skill_name}.skill reference found and will be created"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -199,7 +192,7 @@ validate_skill() {
|
||||
for doc in "$other_skill_dir"/*.md; do
|
||||
[ -f "$doc" ] || continue
|
||||
|
||||
if grep -qE "/${skill_name}\.skill|/${skill_name}-v" "$doc" 2>/dev/null; then
|
||||
if grep -qE "/${skill_name}-v" "$doc" 2>/dev/null; then
|
||||
echo -e " → Referenced by ${other_skill}/$(basename "$doc")"
|
||||
cross_refs_found=true
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Claw Release 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-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Operational notes that make the required maintainer credentials, runtime, and git/GitHub side effects explicit.
|
||||
|
||||
### Changed
|
||||
|
||||
- Declared `bash` alongside the existing `git`, `jq`, and `gh` runtime requirements in skill metadata.
|
||||
- Replaced the documented destructive rollback example with a softer rollback flow that preserves release changes for review.
|
||||
|
||||
### Security
|
||||
|
||||
- Clarified that this internal skill mutates git state, pushes to remotes, and publishes GitHub Releases, so it should only be run from a trusted checkout by maintainers.
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: claw-release
|
||||
version: 0.0.1
|
||||
version: 0.0.2
|
||||
description: Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"🚀","category":"utility","internal":true}}
|
||||
clawdis:
|
||||
emoji: "🚀"
|
||||
requires:
|
||||
bins: [git, jq, gh]
|
||||
bins: [bash, git, jq, gh]
|
||||
---
|
||||
|
||||
# Claw Release
|
||||
@@ -18,6 +18,14 @@ Internal tool for releasing skills and managing the ClawSec catalog.
|
||||
|
||||
---
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Internal maintainer workflow only.
|
||||
- Required runtime: `bash`, `git`, `jq`, `gh`
|
||||
- Required credentials: authenticated GitHub CLI with permission to create releases
|
||||
- Side effects: creates commits, tags, pushes to remote, and publishes GitHub Releases
|
||||
- Trust model: run only from a trusted checkout with a clean working tree and maintainer approval
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Release Type | Command | Tag Format |
|
||||
@@ -93,9 +101,12 @@ Verify at:
|
||||
If you need to undo before pushing:
|
||||
|
||||
```bash
|
||||
git reset --hard HEAD~1 && git tag -d <skill-name>-v<version>
|
||||
git tag -d <skill-name>-v<version>
|
||||
git reset --soft HEAD~1
|
||||
```
|
||||
|
||||
`git reset --soft` preserves the release changes in your working tree so you can inspect or amend them without discarding data.
|
||||
|
||||
---
|
||||
|
||||
## Pre-release Versions
|
||||
@@ -160,6 +171,6 @@ After release, confirm:
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See repository for details.
|
||||
GNU AGPL v3.0 or later - See repository for details.
|
||||
|
||||
Built by the [Prompt Security](https://prompt.security) team.
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "claw-release",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security",
|
||||
"keywords": ["release", "versioning", "deployment", "automation", "ci-cd", "skills"],
|
||||
|
||||
"sbom": {
|
||||
"files": [
|
||||
{ "path": "SKILL.md", "required": true, "description": "Release workflow guide" }
|
||||
{ "path": "SKILL.md", "required": true, "description": "Release workflow guide" },
|
||||
{ "path": "CHANGELOG.md", "required": true, "description": "Version history and release notes" }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -17,7 +18,25 @@
|
||||
"emoji": "🚀",
|
||||
"category": "utility",
|
||||
"internal": true,
|
||||
"requires": { "bins": ["git", "jq", "gh"] },
|
||||
"requires": { "bins": ["bash", "git", "jq", "gh"] },
|
||||
"runtime": {
|
||||
"required_env": [
|
||||
"GH_TOKEN or existing gh auth"
|
||||
],
|
||||
"optional_bins": [
|
||||
"git-lfs"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "No recurring automation; this is a maintainer-invoked release workflow.",
|
||||
"network_egress": "Pushes git commits/tags and creates GitHub Releases when the maintainer runs the documented release flow."
|
||||
},
|
||||
"operator_review": [
|
||||
"Internal maintainer tool only; it mutates git state, tags, and GitHub release metadata.",
|
||||
"Run it only from a trusted checkout with maintainer credentials and a clean working tree.",
|
||||
"Prefer non-destructive rollback steps; avoid rewriting history unless you explicitly intend to."
|
||||
],
|
||||
"triggers": [
|
||||
"release skill",
|
||||
"create release",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
test/
|
||||
@@ -0,0 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the ClawSec ClawHub Checker will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.3] - 2026-04-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Converted setup flow to non-mutating preflight validation; the skill no longer rewrites or copies files into installed `clawsec-suite` directories.
|
||||
- Updated reputation collection to rely on `clawhub inspect --json` security metadata instead of probing `clawhub install` output.
|
||||
- Updated documentation and metadata to describe standalone wrapper usage for guarded install checks.
|
||||
- Added explicit documentation for optional manual advisory-hook wiring when operators want `reputationWarning` fields in advisory alert rendering.
|
||||
|
||||
### Security
|
||||
|
||||
- Removed in-place cross-skill source mutation behavior from setup.
|
||||
- Removed install-output scraping behavior used only to infer VirusTotal status.
|
||||
- Reputation scoring now fails closed when scanner metadata is missing, and hook-level reputation subprocess execution failures are treated as unsafe results.
|
||||
|
||||
## [0.0.2] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Runtime and operator-review metadata describing the suite dependency, ClawHub lookups, and in-place integration behavior.
|
||||
- Preflight disclosure in `scripts/setup_reputation_hook.mjs` before the installed suite is modified.
|
||||
- Regression coverage for setup disclosure in `test/setup_reputation_hook.test.mjs`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Declared `node` and `openclaw` as required runtimes alongside `clawhub` because the integration flow depends on all three.
|
||||
- Documented that setup rewrites installed `clawsec-suite` files rather than operating on a detached copy.
|
||||
|
||||
### Security
|
||||
|
||||
- Made the string-based `handler.ts` rewrite and the remote ClawHub reputation-query behavior explicit so operators can review the mutation and network trust model before enabling it.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ClawSec ClawHub Checker
|
||||
|
||||
A `clawsec-suite` companion skill that adds a standalone reputation gate before guarded installs.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime: `node`, `clawhub`, `openclaw`
|
||||
- Dependency: installed `clawsec-suite`
|
||||
- No in-place mutation of other skills
|
||||
- Advisory-hook wiring is optional and manual in this release
|
||||
- Reputation checks query ClawHub metadata and remain confirmation-gated
|
||||
|
||||
## Purpose
|
||||
|
||||
Adds a second risk signal before install by:
|
||||
|
||||
1. Reading ClawHub inspect/security metadata
|
||||
2. Applying reputation heuristics (age, updates, author activity, downloads)
|
||||
3. Requiring `--confirm-reputation` for low-score installs
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npx clawhub install clawsec-suite
|
||||
npx clawhub install clawsec-clawhub-checker
|
||||
```
|
||||
|
||||
Optional preflight helper:
|
||||
|
||||
```bash
|
||||
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/enhanced_guarded_install.mjs \
|
||||
--skill some-skill \
|
||||
--version 1.0.0
|
||||
```
|
||||
|
||||
Override only after manual review:
|
||||
|
||||
```bash
|
||||
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/enhanced_guarded_install.mjs \
|
||||
--skill some-skill \
|
||||
--version 1.0.0 \
|
||||
--confirm-reputation
|
||||
```
|
||||
|
||||
## Optional Advisory-Hook Wiring
|
||||
|
||||
If you need advisory alerts to include `reputationWarning` / `reputationWarnings`, wire the checker module manually into the installed suite hook:
|
||||
|
||||
- Source: `~/.openclaw/skills/clawsec-clawhub-checker/hooks/clawsec-advisory-guardian/lib/reputation.mjs`
|
||||
- Target: `~/.openclaw/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
|
||||
The setup helper validates paths only and does not patch these files automatically.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0` safe to install
|
||||
- `42` advisory confirmation required
|
||||
- `43` reputation confirmation required
|
||||
- `1` error
|
||||
|
||||
## Configuration
|
||||
|
||||
- `CLAWHUB_REPUTATION_THRESHOLD` (default: 70)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Reputation is heuristic, not authoritative
|
||||
- False positives are possible
|
||||
- Always inspect code before confirming installation
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3.0 or later - Part of the ClawSec security suite
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: clawsec-clawhub-checker
|
||||
version: 0.0.3
|
||||
description: ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "🛡️"
|
||||
requires:
|
||||
bins: [node, clawhub, openclaw]
|
||||
depends_on: [clawsec-suite]
|
||||
---
|
||||
|
||||
# ClawSec ClawHub Checker
|
||||
|
||||
Adds a reputation gate on top of the `clawsec-suite` guarded installer.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime: `node`, `clawhub`, `openclaw`
|
||||
- Depends on: installed `clawsec-suite`
|
||||
- Side effects: none on other skills; this package does not rewrite installed suite files
|
||||
- Advisory-hook wiring is optional and manual in this release
|
||||
- Network behavior: reputation checks call ClawHub inspect/search endpoints
|
||||
- Trust model: scores are heuristic and confirmation-gated
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Reads skill metadata from ClawHub (`inspect --json`)
|
||||
2. Evaluates scanner status (including VirusTotal summary when present)
|
||||
3. Applies additional reputation heuristics (age, updates, author history, downloads)
|
||||
4. Requires explicit `--confirm-reputation` when score is below threshold
|
||||
|
||||
## Installation
|
||||
|
||||
Install after `clawsec-suite`:
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install clawsec-suite
|
||||
npx clawhub@latest install clawsec-clawhub-checker
|
||||
```
|
||||
|
||||
Optional preflight check (validates local paths and prints recommended command):
|
||||
|
||||
```bash
|
||||
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the enhanced installer directly from this skill:
|
||||
|
||||
```bash
|
||||
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/enhanced_guarded_install.mjs \
|
||||
--skill some-skill \
|
||||
--version 1.0.0
|
||||
```
|
||||
|
||||
If a skill is below threshold, rerun only with explicit approval:
|
||||
|
||||
```bash
|
||||
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/enhanced_guarded_install.mjs \
|
||||
--skill some-skill \
|
||||
--version 1.0.0 \
|
||||
--confirm-reputation
|
||||
```
|
||||
|
||||
## Optional Advisory-Hook Wiring (Manual)
|
||||
|
||||
This release does not auto-patch `clawsec-suite` hook files.
|
||||
If you rely on advisory alerts that include `reputationWarning` / `reputationWarnings`, wire the checker module manually:
|
||||
|
||||
- Source module: `~/.openclaw/skills/clawsec-clawhub-checker/hooks/clawsec-advisory-guardian/lib/reputation.mjs`
|
||||
- Target hook file: `~/.openclaw/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
|
||||
Treat that wiring as a deliberate local customization and review it before enabling.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0` safe to install
|
||||
- `42` advisory confirmation required (from clawsec-suite)
|
||||
- `43` reputation confirmation required
|
||||
- `1` error
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `CLAWHUB_REPUTATION_THRESHOLD` - Minimum score (0-100, default: 70)
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- This is defense-in-depth, not a replacement for advisory matching
|
||||
- Scanner outputs can produce false positives and false negatives
|
||||
- Always review skill code before overriding warnings
|
||||
|
||||
## Development
|
||||
|
||||
Key files:
|
||||
|
||||
- `scripts/enhanced_guarded_install.mjs`
|
||||
- `scripts/check_clawhub_reputation.mjs`
|
||||
- `scripts/setup_reputation_hook.mjs`
|
||||
- `hooks/clawsec-advisory-guardian/lib/reputation.mjs`
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3.0 or later - Part of the ClawSec security suite
|
||||
@@ -0,0 +1,119 @@
|
||||
import { spawnSync as runProcessSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Check reputation for a skill
|
||||
* @param {string} skillName - Skill name
|
||||
* @param {string} version - Skill version
|
||||
* @returns {Promise<{safe: boolean, score: number, warnings: string[]}>}
|
||||
*/
|
||||
export async function checkReputation(skillName, version) {
|
||||
const result = {
|
||||
safe: true,
|
||||
score: 100,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Try to get skill slug from directory name or skill.json
|
||||
// For now, use skillName as slug (simplified)
|
||||
const skillSlug = skillName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Run the reputation check script
|
||||
// Current file is at: .../hooks/clawsec-advisory-guardian/lib/reputation.mjs
|
||||
// We need to go up 3 levels to get to the skill root directory
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const checkerDir = path.resolve(__dirname, '../../..');
|
||||
|
||||
const reputationCheck = runProcessSync(
|
||||
"node",
|
||||
[
|
||||
`${checkerDir}/scripts/check_clawhub_reputation.mjs`,
|
||||
skillSlug,
|
||||
version || "",
|
||||
"70" // Default threshold
|
||||
],
|
||||
{ encoding: "utf-8", cwd: checkerDir }
|
||||
);
|
||||
|
||||
if (reputationCheck.error) {
|
||||
result.safe = false;
|
||||
result.score = 0;
|
||||
result.warnings.push(`Reputation check execution error: ${reputationCheck.error.message}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof reputationCheck.status !== "number") {
|
||||
result.safe = false;
|
||||
result.score = 0;
|
||||
result.warnings.push("Reputation check did not return a process exit status");
|
||||
return result;
|
||||
}
|
||||
|
||||
if (reputationCheck.status === 0) {
|
||||
try {
|
||||
const repResult = JSON.parse(reputationCheck.stdout);
|
||||
result.safe = repResult.safe;
|
||||
result.score = repResult.score;
|
||||
result.warnings = repResult.warnings;
|
||||
} catch (parseError) {
|
||||
result.warnings.push(`Failed to parse reputation result: ${parseError.message}`);
|
||||
result.score = 60;
|
||||
result.safe = result.score >= 70;
|
||||
}
|
||||
} else if (reputationCheck.status === 43) {
|
||||
// Reputation warning exit code
|
||||
try {
|
||||
const repResult = JSON.parse(reputationCheck.stdout);
|
||||
result.safe = false;
|
||||
result.score = repResult.score;
|
||||
result.warnings = repResult.warnings;
|
||||
} catch {
|
||||
result.safe = false;
|
||||
result.score = 50;
|
||||
result.warnings.push("Skill flagged by reputation check");
|
||||
}
|
||||
} else {
|
||||
const stderr = (reputationCheck.stderr || "").trim();
|
||||
const stdout = (reputationCheck.stdout || "").trim();
|
||||
const output = [stderr, stdout].filter((entry) => entry).join(" | ");
|
||||
result.warnings.push(
|
||||
`Reputation check failed with exit code ${reputationCheck.status}${
|
||||
output ? `: ${output}` : ""
|
||||
}`,
|
||||
);
|
||||
result.score = 0;
|
||||
result.safe = false;
|
||||
}
|
||||
} catch (error) {
|
||||
result.warnings.push(`Reputation check error: ${error.message}`);
|
||||
result.score = 50;
|
||||
result.safe = result.score >= 70;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reputation warning for alert messages
|
||||
* @param {{score: number, warnings: string[]}} reputationInfo
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatReputationWarning(reputationInfo) {
|
||||
if (!reputationInfo || reputationInfo.score >= 70) return "";
|
||||
|
||||
const lines = [
|
||||
`\n⚠️ **REPUTATION WARNING** (Score: ${reputationInfo.score}/100)`,
|
||||
];
|
||||
|
||||
if (reputationInfo.warnings.length > 0) {
|
||||
lines.push("");
|
||||
reputationInfo.warnings.forEach(w => lines.push(`• ${w}`));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("This skill has low reputation score. Review carefully before installation.");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync as runProcessSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
function runClawhub(args) {
|
||||
return runProcessSync("clawhub", args, { encoding: "utf-8" });
|
||||
}
|
||||
|
||||
function toPublicResult(result) {
|
||||
return {
|
||||
safe: result.safe,
|
||||
score: result.score,
|
||||
warnings: result.warnings,
|
||||
virustotal: result.virustotal,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeResult(result, threshold) {
|
||||
result.score = Math.max(0, Math.min(100, result.score));
|
||||
result.safe = !result.blocked && result.score >= threshold;
|
||||
if (!result.safe) {
|
||||
const thresholdWarning = `Reputation score ${result.score}/100 below threshold ${threshold}/100`;
|
||||
if (!result.warnings.includes(thresholdWarning)) {
|
||||
result.warnings.unshift(thresholdWarning);
|
||||
}
|
||||
}
|
||||
return toPublicResult(result);
|
||||
}
|
||||
|
||||
function blockOnMissingScannerData(result, warning) {
|
||||
result.warnings.push(warning);
|
||||
result.score = Math.min(result.score, 60);
|
||||
result.blocked = true;
|
||||
}
|
||||
|
||||
function parseJson(raw, label, warnings) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Failed to parse ${label}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeApplyVersionSecuritySignals(result, versionDetails) {
|
||||
if (!versionDetails || typeof versionDetails !== "object") {
|
||||
blockOnMissingScannerData(result, "ClawHub version security details are unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
const security = versionDetails.security;
|
||||
if (!security || typeof security !== "object") {
|
||||
blockOnMissingScannerData(result, "ClawHub version record does not include security scanner output");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof security.status === "string" && security.status.toLowerCase() === "suspicious") {
|
||||
result.warnings.push("ClawHub static moderation marked the version as suspicious");
|
||||
result.score -= 30;
|
||||
}
|
||||
|
||||
const scanners = security.scanners;
|
||||
if (!scanners || typeof scanners !== "object") {
|
||||
blockOnMissingScannerData(result, "ClawHub scanner breakdown is missing from version metadata");
|
||||
return;
|
||||
}
|
||||
|
||||
const vt = scanners.vt;
|
||||
if (!vt || typeof vt !== "object") {
|
||||
blockOnMissingScannerData(result, "VirusTotal scanner data was not returned by ClawHub");
|
||||
return;
|
||||
}
|
||||
|
||||
const vtStatus =
|
||||
(typeof vt.normalizedStatus === "string" && vt.normalizedStatus) ||
|
||||
(typeof vt.status === "string" && vt.status) ||
|
||||
(typeof vt.verdict === "string" && vt.verdict) ||
|
||||
"";
|
||||
const normalizedStatus = vtStatus.toLowerCase();
|
||||
|
||||
if (normalizedStatus === "suspicious") {
|
||||
result.virustotal.push("ClawHub VirusTotal scan returned suspicious");
|
||||
result.score -= 40;
|
||||
|
||||
const vtSummary = typeof vt.analysis === "string" ? vt.analysis.trim() : "";
|
||||
if (vtSummary) {
|
||||
result.virustotal.push(vtSummary.split("\n")[0]);
|
||||
}
|
||||
} else if (normalizedStatus === "clean" || normalizedStatus === "benign") {
|
||||
result.virustotal.push("ClawHub VirusTotal scan returned clean");
|
||||
} else if (normalizedStatus) {
|
||||
result.warnings.push(`VirusTotal scanner status reported as: ${normalizedStatus}`);
|
||||
result.score -= 10;
|
||||
} else {
|
||||
result.warnings.push("VirusTotal scanner status was unavailable");
|
||||
result.score -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check ClawHub reputation for a skill
|
||||
* @param {string} skillSlug - Skill slug to check
|
||||
* @param {string} version - Optional version
|
||||
* @param {number} threshold - Minimum reputation score (0-100)
|
||||
* @returns {Promise<{safe: boolean, score: number, warnings: string[], virustotal: string[]}>}
|
||||
*/
|
||||
export async function checkClawhubReputation(skillSlug, version, threshold = 70) {
|
||||
const result = {
|
||||
safe: true,
|
||||
score: 100,
|
||||
warnings: [],
|
||||
virustotal: [],
|
||||
blocked: false,
|
||||
};
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(skillSlug)) {
|
||||
result.warnings.push(`Invalid skill slug: ${skillSlug}`);
|
||||
result.score = 0;
|
||||
result.safe = false;
|
||||
result.blocked = true;
|
||||
return toPublicResult(result);
|
||||
}
|
||||
|
||||
if (version && !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(version)) {
|
||||
result.warnings.push(`Invalid version format: ${version}`);
|
||||
result.score = 0;
|
||||
result.safe = false;
|
||||
result.blocked = true;
|
||||
return toPublicResult(result);
|
||||
}
|
||||
|
||||
try {
|
||||
const inspectArgs = ["inspect", skillSlug, "--json"];
|
||||
if (version) inspectArgs.push("--version", version);
|
||||
const inspectResult = runClawhub(inspectArgs);
|
||||
|
||||
if (inspectResult.status !== 0) {
|
||||
result.warnings.push(`Skill "${skillSlug}" not found or cannot be inspected`);
|
||||
result.score = Math.min(result.score, 40);
|
||||
result.blocked = true;
|
||||
return finalizeResult(result, threshold);
|
||||
}
|
||||
|
||||
const skillInfo = parseJson(inspectResult.stdout, "skill inspection payload", result.warnings);
|
||||
if (!skillInfo) {
|
||||
result.score = Math.min(result.score, 40);
|
||||
result.blocked = true;
|
||||
return finalizeResult(result, threshold);
|
||||
}
|
||||
|
||||
if (skillInfo.skill?.createdAt) {
|
||||
const createdMs = skillInfo.skill.createdAt;
|
||||
const ageDays = (Date.now() - createdMs) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (ageDays < 7) {
|
||||
result.warnings.push(`Skill is less than 7 days old (${ageDays.toFixed(1)} days)`);
|
||||
result.score -= 15;
|
||||
} else if (ageDays < 30) {
|
||||
result.warnings.push(`Skill is less than 30 days old (${ageDays.toFixed(1)} days)`);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (skillInfo.skill?.updatedAt && skillInfo.skill?.createdAt) {
|
||||
const updatedMs = skillInfo.skill.updatedAt;
|
||||
const createdMs = skillInfo.skill.createdAt;
|
||||
const updateAgeDays = (Date.now() - updatedMs) / (1000 * 60 * 60 * 24);
|
||||
const totalAgeDays = (Date.now() - createdMs) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (updateAgeDays > 90 && totalAgeDays > 90) {
|
||||
result.warnings.push(`Skill hasn't been updated in ${updateAgeDays.toFixed(0)} days`);
|
||||
result.score -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
if (skillInfo.owner?.handle) {
|
||||
const authorResult = runClawhub(["search", skillInfo.owner.handle]);
|
||||
if (authorResult.status === 0) {
|
||||
const lines = authorResult.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line);
|
||||
const skillCount = Math.max(0, lines.length - 1);
|
||||
|
||||
if (skillCount === 1) {
|
||||
result.warnings.push(`Author "${skillInfo.owner.handle}" has only 1 published skill`);
|
||||
result.score -= 10;
|
||||
} else if (skillCount > 1 && skillCount < 3) {
|
||||
result.warnings.push(
|
||||
`Author "${skillInfo.owner.handle}" has only ${skillCount} published skills`,
|
||||
);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skillInfo.skill?.stats?.downloads !== undefined) {
|
||||
const downloads = skillInfo.skill.stats.downloads;
|
||||
if (downloads < 10) {
|
||||
result.warnings.push(`Low download count: ${downloads}`);
|
||||
result.score -= 10;
|
||||
} else if (downloads < 100) {
|
||||
result.warnings.push(`Moderate download count: ${downloads}`);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
let versionDetails = skillInfo.version ?? null;
|
||||
if (!versionDetails && !version && skillInfo.latestVersion?.version) {
|
||||
const latestVersionCheck = runClawhub([
|
||||
"inspect",
|
||||
skillSlug,
|
||||
"--version",
|
||||
String(skillInfo.latestVersion.version),
|
||||
"--json",
|
||||
]);
|
||||
if (latestVersionCheck.status === 0) {
|
||||
const latestInfo = parseJson(
|
||||
latestVersionCheck.stdout,
|
||||
"latest-version inspection payload",
|
||||
result.warnings,
|
||||
);
|
||||
versionDetails = latestInfo?.version ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
maybeApplyVersionSecuritySignals(result, versionDetails);
|
||||
return finalizeResult(result, threshold);
|
||||
} catch (error) {
|
||||
result.warnings.push(`Reputation check error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
result.score = 50;
|
||||
result.blocked = true;
|
||||
return finalizeResult(result, threshold);
|
||||
}
|
||||
}
|
||||
|
||||
const isCliEntrypoint =
|
||||
process.argv[1] !== undefined &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||
|
||||
if (isCliEntrypoint) {
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
console.error("Usage: node check_clawhub_reputation.mjs <skill-slug> [version] [threshold]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const skillSlug = args[0];
|
||||
const version = args[1] || "";
|
||||
let threshold = 70;
|
||||
|
||||
if (args[2] !== undefined) {
|
||||
const parsedThreshold = parseInt(args[2], 10);
|
||||
if (!Number.isInteger(parsedThreshold) || parsedThreshold < 0 || parsedThreshold > 100) {
|
||||
console.error(
|
||||
`Invalid threshold: "${args[2]}". Threshold must be an integer between 0 and 100.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
threshold = parsedThreshold;
|
||||
}
|
||||
|
||||
const result = await checkClawhubReputation(skillSlug, version, threshold);
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.safe) {
|
||||
process.exit(43);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync as runProcessSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { checkClawhubReputation } from "./check_clawhub_reputation.mjs";
|
||||
|
||||
const EXIT_ADVISORY_CONFIRM_REQUIRED = 42;
|
||||
const EXIT_REPUTATION_CONFIRM_REQUIRED = 43;
|
||||
|
||||
function printUsage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill <skill-name> [--version <version>] [--confirm-advisory] [--confirm-reputation] [--dry-run] [--reputation-threshold <score>]",
|
||||
"",
|
||||
"Examples:",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill helper-plus --version 1.0.1",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill helper-plus --version 1.0.1 --confirm-advisory --confirm-reputation",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill suspicious-skill --reputation-threshold 80",
|
||||
"",
|
||||
"Exit codes:",
|
||||
" 0 success / no advisory or reputation block",
|
||||
" 42 advisory matched and second confirmation is required",
|
||||
" 43 reputation warning and second confirmation is required",
|
||||
" 1 error",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
// Parse and validate CLAWHUB_REPUTATION_THRESHOLD environment variable
|
||||
let defaultThreshold = 70;
|
||||
const envThreshold = process.env.CLAWHUB_REPUTATION_THRESHOLD;
|
||||
|
||||
if (envThreshold !== undefined && envThreshold !== "") {
|
||||
const parsedEnv = parseInt(envThreshold, 10);
|
||||
if (Number.isNaN(parsedEnv) || parsedEnv < 0 || parsedEnv > 100) {
|
||||
throw new Error(
|
||||
`Invalid CLAWHUB_REPUTATION_THRESHOLD environment variable: "${envThreshold}". Must be between 0 and 100.`
|
||||
);
|
||||
}
|
||||
defaultThreshold = parsedEnv;
|
||||
}
|
||||
|
||||
const parsed = {
|
||||
skill: "",
|
||||
version: "",
|
||||
confirmAdvisory: false,
|
||||
confirmReputation: false,
|
||||
dryRun: false,
|
||||
reputationThreshold: defaultThreshold,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === "--skill") {
|
||||
parsed.skill = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--version") {
|
||||
parsed.version = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm-advisory") {
|
||||
parsed.confirmAdvisory = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm-reputation") {
|
||||
parsed.confirmReputation = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--reputation-threshold") {
|
||||
parsed.reputationThreshold = parseInt(String(argv[i + 1] ?? "70"), 10);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--help" || token === "-h") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
|
||||
if (!parsed.skill) {
|
||||
throw new Error("Missing required argument: --skill");
|
||||
}
|
||||
// Must start with alphanumeric, then can contain hyphens (matches check_clawhub_reputation.mjs validation)
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(parsed.skill)) {
|
||||
throw new Error("Invalid --skill value. Must start with a letter or digit, followed by lowercase letters, digits, and hyphens.");
|
||||
}
|
||||
if (parsed.version && !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(parsed.version)) {
|
||||
throw new Error(
|
||||
"Invalid --version value. Must be semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.45)."
|
||||
);
|
||||
}
|
||||
if (parsed.reputationThreshold < 0 || parsed.reputationThreshold > 100 || Number.isNaN(parsed.reputationThreshold)) {
|
||||
throw new Error("Invalid --reputation-threshold value. Must be between 0 and 100.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildOriginalArgs(argv) {
|
||||
// Filter out reputation-specific arguments that the original script doesn't understand
|
||||
const originalArgs = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === "--confirm-reputation" || token === "--reputation-threshold") {
|
||||
// Skip reputation-specific flags
|
||||
if (token === "--reputation-threshold" && i + 1 < argv.length) {
|
||||
// Also skip the value associated with --reputation-threshold
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
originalArgs.push(token);
|
||||
}
|
||||
|
||||
return originalArgs;
|
||||
}
|
||||
|
||||
async function runOriginalGuardedInstall(args) {
|
||||
// Find the original guarded_skill_install.mjs from clawsec-suite
|
||||
const suiteDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const originalScript = path.join(suiteDir, "scripts", "guarded_skill_install.mjs");
|
||||
|
||||
try {
|
||||
await fs.access(originalScript);
|
||||
} catch {
|
||||
throw new Error(`Original guarded_skill_install.mjs not found at ${originalScript}. Is clawsec-suite installed?`);
|
||||
}
|
||||
|
||||
// Pass through environment without modification
|
||||
// The original guarded_skill_install.mjs handles --confirm-advisory properly
|
||||
const child = runProcessSync(
|
||||
"node",
|
||||
[originalScript, ...args.originalArgs],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
cwd: suiteDir,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
exitCode: child.status ?? 1,
|
||||
signal: child.signal,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const args = parseArgs(cliArgs);
|
||||
|
||||
// Build args for original script (excluding reputation-specific args)
|
||||
args.originalArgs = buildOriginalArgs(cliArgs);
|
||||
|
||||
// Step 1: Check reputation (unless already confirmed)
|
||||
if (!args.confirmReputation) {
|
||||
console.log(`Checking ClawHub reputation for ${args.skill}${args.version ? `@${args.version}` : ""}...`);
|
||||
|
||||
const reputationResult = await checkClawhubReputation(args.skill, args.version, args.reputationThreshold);
|
||||
|
||||
if (!reputationResult.safe) {
|
||||
console.error("\n" + "=".repeat(80));
|
||||
console.error("REPUTATION WARNING");
|
||||
console.error("=".repeat(80));
|
||||
console.error(`Skill "${args.skill}" has low reputation score: ${reputationResult.score}/100`);
|
||||
console.error(`Threshold: ${args.reputationThreshold}/100`);
|
||||
console.error("");
|
||||
|
||||
if (reputationResult.warnings.length > 0) {
|
||||
console.error("Warnings:");
|
||||
reputationResult.warnings.forEach(w => console.error(` • ${w}`));
|
||||
console.error("");
|
||||
}
|
||||
|
||||
if (reputationResult.virustotal) {
|
||||
console.error("VirusTotal Code Insight flags:");
|
||||
reputationResult.virustotal.forEach(v => console.error(` • ${v}`));
|
||||
console.error("");
|
||||
}
|
||||
|
||||
console.error("To install despite reputation warning, run with --confirm-reputation flag:");
|
||||
console.error(` node ${process.argv[1]} --skill ${args.skill}${args.version ? ` --version ${args.version}` : ""} --confirm-reputation`);
|
||||
console.error("");
|
||||
console.error("=".repeat(80));
|
||||
|
||||
process.exit(EXIT_REPUTATION_CONFIRM_REQUIRED);
|
||||
}
|
||||
|
||||
console.log(`✓ Reputation check passed: ${reputationResult.score}/100`);
|
||||
} else {
|
||||
console.log(`⚠️ Reputation confirmation override enabled for ${args.skill}`);
|
||||
}
|
||||
|
||||
// Step 2: Run original guarded installer (handles advisory checks)
|
||||
console.log("\nRunning advisory checks...");
|
||||
const result = await runOriginalGuardedInstall(args);
|
||||
|
||||
if (result.exitCode !== 0 && result.exitCode !== EXIT_ADVISORY_CONFIRM_REQUIRED) {
|
||||
process.exit(result.exitCode);
|
||||
}
|
||||
|
||||
// If we get here, either success (0) or advisory confirmation required (42)
|
||||
process.exit(result.exitCode);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function printUsage() {
|
||||
console.log([
|
||||
"Usage:",
|
||||
" node scripts/setup_reputation_hook.mjs",
|
||||
"",
|
||||
"This helper no longer mutates installed clawsec-suite files.",
|
||||
"It validates local prerequisites and prints the standalone checker command.",
|
||||
"",
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
function printSummary({ suiteDir, checkerDir, enhancedInstaller }) {
|
||||
const lines = [
|
||||
"Preflight review:",
|
||||
"- This setup does not rewrite files in other skills.",
|
||||
`- It validates expected install paths: ${suiteDir} and ${checkerDir}.`,
|
||||
"- Required runtime for reputation checks: node + clawhub.",
|
||||
"- Advisory-hook reputation annotations are manual only in this release.",
|
||||
"- If you want hook alert annotations, wire checker lib/reputation.mjs into suite handler.ts yourself.",
|
||||
"- Reputation scoring is heuristic and must remain confirmation-gated.",
|
||||
"",
|
||||
"Recommended command:",
|
||||
` node ${enhancedInstaller} --skill <slug> [--version <semver>]`,
|
||||
"",
|
||||
"Optional shell alias (manual, not applied automatically):",
|
||||
` alias clawsec-guarded-install='node ${enhancedInstaller}'`,
|
||||
];
|
||||
|
||||
console.log(lines.join("\n"));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const suiteDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const checkerDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-clawhub-checker");
|
||||
const enhancedInstaller = path.join(checkerDir, "scripts", "enhanced_guarded_install.mjs");
|
||||
const suiteGuardedInstaller = path.join(suiteDir, "scripts", "guarded_skill_install.mjs");
|
||||
|
||||
await fs.access(checkerDir);
|
||||
await fs.access(enhancedInstaller);
|
||||
await fs.access(suiteDir);
|
||||
await fs.access(suiteGuardedInstaller);
|
||||
|
||||
printSummary({ suiteDir, checkerDir, enhancedInstaller });
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`Setup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"name": "clawsec-clawhub-checker",
|
||||
"version": "0.0.3",
|
||||
"description": "ClawHub reputation checker for clawsec-suite. Adds a standalone reputation gate before guarded skill installation.",
|
||||
"author": "abutbul",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
"keywords": [
|
||||
"security",
|
||||
"reputation",
|
||||
"clawhub",
|
||||
"virustotal",
|
||||
"skills",
|
||||
"installer",
|
||||
"verification",
|
||||
"defense-in-depth",
|
||||
"openclaw"
|
||||
],
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "Skill documentation and usage guide"
|
||||
},
|
||||
{
|
||||
"path": "scripts/enhanced_guarded_install.mjs",
|
||||
"required": true,
|
||||
"description": "Enhanced guarded installer with reputation checks"
|
||||
},
|
||||
{
|
||||
"path": "scripts/check_clawhub_reputation.mjs",
|
||||
"required": true,
|
||||
"description": "ClawHub reputation checking logic"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_reputation_hook.mjs",
|
||||
"required": true,
|
||||
"description": "Non-mutating preflight helper that validates paths and prints recommended commands"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/reputation.mjs",
|
||||
"required": false,
|
||||
"description": "Optional reputation module for advisory guardian integrations"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"required": false,
|
||||
"description": "Additional documentation and development guide"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and release notes"
|
||||
},
|
||||
{
|
||||
"path": "test/reputation_check.test.mjs",
|
||||
"required": false,
|
||||
"description": "Test suite for reputation checking functionality"
|
||||
},
|
||||
{
|
||||
"path": "test/setup_reputation_hook.test.mjs",
|
||||
"required": false,
|
||||
"description": "Regression coverage for setup preflight behavior"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"clawsec-suite": ">=0.0.10"
|
||||
},
|
||||
"integration": {
|
||||
"clawsec-suite": {
|
||||
"enhances": [
|
||||
"guarded_skill_install.mjs via external wrapper invocation",
|
||||
"optional manual advisory-guardian hook wiring for reputation annotations"
|
||||
],
|
||||
"adds_exit_codes": {
|
||||
"43": "Reputation warning - requires --confirm-reputation"
|
||||
},
|
||||
"adds_arguments": [
|
||||
"--confirm-reputation",
|
||||
"--reputation-threshold"
|
||||
]
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"emoji": "🛡️",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"node",
|
||||
"clawhub",
|
||||
"openclaw"
|
||||
]
|
||||
},
|
||||
"runtime": {
|
||||
"required_env": [],
|
||||
"optional_env": [
|
||||
"CLAWHUB_REPUTATION_THRESHOLD"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "No automatic persistence; setup helper performs validation only and does not rewrite other skills.",
|
||||
"network_egress": "Reputation checks query ClawHub inspect/search endpoints for metadata and scanner summaries."
|
||||
},
|
||||
"operator_review": [
|
||||
"Requires an installed clawsec-suite checkout because the enhanced installer delegates to suite guarded install flow.",
|
||||
"This release does not auto-wire advisory-guardian hook annotations; if needed, wire hooks/clawsec-advisory-guardian/lib/reputation.mjs manually into the suite hook.",
|
||||
"Reputation results are heuristic and can produce false positives; installation still requires explicit user confirmation for risky skills.",
|
||||
"Run the setup helper to confirm local paths before using the enhanced installer command."
|
||||
],
|
||||
"triggers": [
|
||||
"clawhub reputation",
|
||||
"skill reputation check",
|
||||
"virustotal skill check",
|
||||
"safe skill install",
|
||||
"check skill safety",
|
||||
"skill security score"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Reputation check tests for clawsec-clawhub-checker.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Input validation (command injection prevention)
|
||||
* - Reputation scoring with mocked clawhub output
|
||||
* - formatReputationWarning output formatting
|
||||
* - Enhanced installer argument parsing
|
||||
*
|
||||
* Run: node skills/clawsec-clawhub-checker/test/reputation_check.test.mjs
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CHECKER_SCRIPT = path.resolve(__dirname, "..", "scripts", "check_clawhub_reputation.mjs");
|
||||
const ENHANCED_INSTALL_SCRIPT = path.resolve(__dirname, "..", "scripts", "enhanced_guarded_install.mjs");
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount++;
|
||||
console.log(`\u2713 ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`\u2717 ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function runScript(scriptPath, args, env) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("node", [scriptPath, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid skill slug is rejected (command injection prevention)
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidSlugRejected() {
|
||||
const testName = "reputation_check: invalid slug with shell metacharacters is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test; rm -rf /', '', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false && parsed.warnings.some(w => w.includes("Invalid skill slug"))) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected score 0 with invalid slug warning, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid version format is rejected (command injection prevention)
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidVersionRejected() {
|
||||
const testName = "reputation_check: invalid version with shell metacharacters is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0; curl evil.com', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false && parsed.warnings.some(w => w.includes("Invalid version format"))) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected score 0 with invalid version warning, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Valid slug and version pass input validation
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testValidInputsAccepted() {
|
||||
const testName = "reputation_check: valid slug and semver pass input validation";
|
||||
try {
|
||||
// clawhub is not installed, so the check will fail at the inspect step,
|
||||
// but it should NOT fail at input validation
|
||||
const result = await runScript(CHECKER_SCRIPT, ['my-test-skill', '1.0.0', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Should not contain input validation errors
|
||||
const hasInputError = parsed.warnings.some(
|
||||
w => w.includes("Invalid skill slug") || w.includes("Invalid version format")
|
||||
);
|
||||
if (!hasInputError) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Valid inputs were rejected: ${JSON.stringify(parsed.warnings)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Slug with uppercase or special chars is rejected
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testUppercaseSlugRejected() {
|
||||
const testName = "reputation_check: uppercase slug is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['Test-Skill', '1.0.0', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected uppercase slug to be rejected, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Empty slug shows usage error
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEmptySlugShowsUsage() {
|
||||
const testName = "reputation_check: empty slug shows usage error";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, []);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Usage:")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with usage message, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Version with pre-release tag is accepted
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testPreReleaseVersionAccepted() {
|
||||
const testName = "reputation_check: pre-release version format is accepted";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0-beta.1', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasVersionError = parsed.warnings.some(w => w.includes("Invalid version format"));
|
||||
if (!hasVersionError) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Pre-release version was rejected: ${JSON.stringify(parsed.warnings)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: CLI entrypoint guard works when script path is relative
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testRelativePathCliEntrypointWorks() {
|
||||
const testName = "reputation_check: CLI entrypoint works with relative script path";
|
||||
try {
|
||||
const relativeCheckerScript = path.relative(process.cwd(), CHECKER_SCRIPT);
|
||||
const result = await runScript(relativeCheckerScript, ['bad slug', '', '70']);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output with relative script path: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.code === 43 &&
|
||||
parsed.safe === false &&
|
||||
parsed.warnings.some((w) => w.includes("Invalid skill slug"))
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 43 with invalid slug warning via relative path, got code ${result.code}: ${JSON.stringify(parsed)}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid threshold format is rejected in CLI mode
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidThresholdRejected() {
|
||||
const testName = "reputation_check: invalid threshold is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0', 'abc']);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid threshold")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 1 with invalid threshold message, got code ${result.code}: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer rejects invalid skill name
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidSkill() {
|
||||
const testName = "enhanced_install: rejects skill name with invalid characters";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, ['--skill', 'bad skill!']);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --skill value")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with invalid skill error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer requires --skill argument
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRequiresSkill() {
|
||||
const testName = "enhanced_install: requires --skill argument";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, []);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Missing required argument")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with missing argument error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer rejects invalid threshold
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidThreshold() {
|
||||
const testName = "enhanced_install: rejects invalid reputation threshold";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, [
|
||||
'--skill', 'test-skill', '--reputation-threshold', '150'
|
||||
]);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --reputation-threshold")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with invalid threshold error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: formatReputationWarning
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testFormatReputationWarning() {
|
||||
const testName = "reputation: formatReputationWarning formats correctly";
|
||||
try {
|
||||
const { formatReputationWarning } = await import(
|
||||
path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs")
|
||||
);
|
||||
|
||||
// Safe reputation — should return empty
|
||||
const safeResult = formatReputationWarning({ score: 80, warnings: [] });
|
||||
if (safeResult !== "") {
|
||||
fail(testName, `Expected empty string for safe score, got: "${safeResult}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unsafe reputation — should contain warning
|
||||
const unsafeResult = formatReputationWarning({ score: 45, warnings: ["Low downloads", "New author"] });
|
||||
if (
|
||||
unsafeResult.includes("REPUTATION WARNING") &&
|
||||
unsafeResult.includes("45/100") &&
|
||||
unsafeResult.includes("Low downloads") &&
|
||||
unsafeResult.includes("New author")
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Unexpected format: "${unsafeResult}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: formatReputationWarning handles null/undefined
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testFormatReputationWarningNull() {
|
||||
const testName = "reputation: formatReputationWarning handles null input";
|
||||
try {
|
||||
const { formatReputationWarning } = await import(
|
||||
path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs")
|
||||
);
|
||||
|
||||
const nullResult = formatReputationWarning(null);
|
||||
const undefinedResult = formatReputationWarning(undefined);
|
||||
|
||||
if (nullResult === "" && undefinedResult === "") {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected empty for null/undefined, got: "${nullResult}", "${undefinedResult}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer validates --version even with --confirm-reputation
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidVersion() {
|
||||
const testName = "enhanced_install: rejects invalid version format even with --confirm-reputation";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, [
|
||||
'--skill', 'test-skill', '--version', '1.0.0;rm -rf /', '--confirm-reputation'
|
||||
]);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --version value")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 1 with invalid version message, got code ${result.code}: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main test runner
|
||||
// -----------------------------------------------------------------------------
|
||||
async function runTests() {
|
||||
console.log("=== ClawSec ClawHub Checker Tests ===\n");
|
||||
|
||||
await testInvalidSlugRejected();
|
||||
await testInvalidVersionRejected();
|
||||
await testValidInputsAccepted();
|
||||
await testUppercaseSlugRejected();
|
||||
await testEmptySlugShowsUsage();
|
||||
await testPreReleaseVersionAccepted();
|
||||
await testRelativePathCliEntrypointWorks();
|
||||
await testInvalidThresholdRejected();
|
||||
await testEnhancedInstallerRejectsInvalidSkill();
|
||||
await testEnhancedInstallerRequiresSkill();
|
||||
await testEnhancedInstallerRejectsInvalidVersion();
|
||||
await testEnhancedInstallerRejectsInvalidThreshold();
|
||||
await testFormatReputationWarning();
|
||||
await testFormatReputationWarningNull();
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
console.error("Test runner failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTempDir, pass, fail, report, exitWithResults } from "../../clawsec-suite/test/lib/test_harness.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const NODE_BIN = process.execPath;
|
||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "setup_reputation_hook.mjs");
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||
|
||||
async function runScript(env) {
|
||||
return await new Promise((resolve) => {
|
||||
const proc = spawn(NODE_BIN, [SCRIPT_PATH], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stageInstalledSkill(tempHome, skillName) {
|
||||
const sourceDir = path.join(REPO_ROOT, "skills", skillName);
|
||||
const destDir = path.join(tempHome, ".openclaw", "skills", skillName);
|
||||
await fs.mkdir(path.dirname(destDir), { recursive: true });
|
||||
await fs.cp(sourceDir, destDir, { recursive: true });
|
||||
return destDir;
|
||||
}
|
||||
|
||||
async function testPreflightSummaryNoMutation() {
|
||||
const testName = "setup_reputation_hook: prints preflight review without mutating installed suite files";
|
||||
const tmp = await createTempDir();
|
||||
const homeDir = path.join(tmp.path, "home");
|
||||
|
||||
try {
|
||||
await stageInstalledSkill(homeDir, "clawsec-suite");
|
||||
await stageInstalledSkill(homeDir, "clawsec-clawhub-checker");
|
||||
|
||||
const result = await runScript({
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `script failed: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapperPath = path.join(
|
||||
homeDir,
|
||||
".openclaw",
|
||||
"skills",
|
||||
"clawsec-suite",
|
||||
"scripts",
|
||||
"guarded_skill_install_wrapper.mjs",
|
||||
);
|
||||
const reputationModulePath = path.join(
|
||||
homeDir,
|
||||
".openclaw",
|
||||
"skills",
|
||||
"clawsec-suite",
|
||||
"hooks",
|
||||
"clawsec-advisory-guardian",
|
||||
"lib",
|
||||
"reputation.mjs",
|
||||
);
|
||||
const wrapperExists = await fs
|
||||
.access(wrapperPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const reputationModuleExists = await fs
|
||||
.access(reputationModulePath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (
|
||||
result.stdout.includes("Preflight review:") &&
|
||||
result.stdout.includes("does not rewrite files in other skills") &&
|
||||
result.stdout.includes("Recommended command:") &&
|
||||
result.stdout.includes("alias clawsec-guarded-install") &&
|
||||
wrapperExists === false &&
|
||||
reputationModuleExists === false
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `missing preflight detail: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
await testPreflightSummaryNoMutation();
|
||||
report();
|
||||
exitWithResults();
|
||||
}
|
||||
|
||||
runAllTests().catch((err) => {
|
||||
console.error("Test runner failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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.6] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- Operational notes in the skill docs that distinguish standalone feed installation from `clawsec-suite` automation responsibilities.
|
||||
- Metadata describing required standalone install tooling and operator review expectations.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clarified that the standalone feed package does not itself create persistence, hooks, or cron jobs.
|
||||
- Declared checksum/extraction tooling used by the documented install flow (`bash`, `shasum`, `unzip`) in skill metadata.
|
||||
- Normalized product naming in the skill docs to use OpenClaw terminology.
|
||||
|
||||
### Security
|
||||
|
||||
- Made release-provenance and checksum verification expectations explicit for standalone installations on production hosts.
|
||||
|
||||
## [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`.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence and stay informed about emerging threats.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime for standalone installation: `bash`, `curl`, `jq`, `shasum`, `unzip`
|
||||
- This package is advisory data plus install/update guidance; it does not create local persistence by itself
|
||||
- Automated polling, installed-skill cross-referencing, and hook/cron behavior live in `clawsec-suite`
|
||||
- Verify release provenance and checksums before installing the standalone artifact on production hosts
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Advisories** - Get notified about malicious skills, vulnerabilities, and attack patterns
|
||||
@@ -62,4 +69,4 @@ HIGH - GA-2026-016: Vulnerable skill "data-helper"
|
||||
|
||||
## License
|
||||
|
||||
MIT License - [Prompt Security](https://prompt.security)
|
||||
GNU AGPL v3.0 or later - [Prompt Security](https://prompt.security)
|
||||
|
||||
+113
-10
@@ -1,20 +1,27 @@
|
||||
---
|
||||
name: clawsec-feed
|
||||
version: 0.0.2
|
||||
description: Security advisory feed with automated NVD CVE polling for OpenClaw-related vulnerabilities. Updated daily.
|
||||
version: 0.0.6
|
||||
description: Security advisory feed package for OpenClaw-related threats and vulnerabilities. The upstream feed is updated daily; local automation is handled by clawsec-suite or the operator.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"📡","category":"security"}}
|
||||
clawdis:
|
||||
emoji: "📡"
|
||||
requires:
|
||||
bins: [curl, jq]
|
||||
bins: [bash, curl, jq, shasum, unzip]
|
||||
---
|
||||
|
||||
# ClawSec Feed 📡
|
||||
|
||||
Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence and stay informed about emerging threats.
|
||||
|
||||
This feed is automatically updated daily with CVEs related to OpenClaw, clawdbot, and Moltbot from the NIST National Vulnerability Database (NVD).
|
||||
This feed is automatically updated daily with CVEs related to OpenClaw and Moltbot from the NIST National Vulnerability Database (NVD).
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Required runtime for standalone installation: `bash`, `curl`, `jq`, `shasum`, `unzip`
|
||||
- Side effects: standalone install only writes local skill files
|
||||
- Network behavior: downloads release metadata/artifacts and, if you choose to poll manually, fetches the advisory feed
|
||||
- Trust model: this package does not itself create cron jobs or submit data externally; automation is delegated to `clawsec-suite` or your own scheduler
|
||||
|
||||
**An open source project by [Prompt Security](https://prompt.security)**
|
||||
|
||||
@@ -52,9 +59,13 @@ Install clawsec-feed independently without the full suite.
|
||||
|
||||
Continue below for standalone installation instructions.
|
||||
|
||||
Standalone installation is a network download workflow. Verify the release source and the provided checksums before installing it on production hosts.
|
||||
|
||||
---
|
||||
|
||||
Installation Steps:
|
||||
Agents should review scripts before execution!
|
||||
|
||||
|
||||
```bash
|
||||
# Get latest clawsec-feed release tag
|
||||
@@ -316,7 +327,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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -383,6 +396,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
|
||||
@@ -474,23 +523,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)
|
||||
|
||||
---
|
||||
|
||||
@@ -501,11 +602,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:
|
||||
@@ -640,8 +743,8 @@ fi
|
||||
|
||||
**Bootstrap Trust Problem:** The initial download of this skill cannot be verified by the skill itself. To establish trust:
|
||||
|
||||
1. **Verify the source URL** - Ensure you are downloading from `github.com/prompt-security/clawsec`
|
||||
2. **Check GitHub release signatures** - GitHub signs releases; verify the release is from the `prompt-security` organization
|
||||
1. **Verify the source URL** - Ensure you are downloading from `https://clawsec.prompt.security`
|
||||
2. **Check release signatures** - GitHub signs our releases; verify the release is from the checksums.
|
||||
3. **Compare checksums** - After download, compare the SHA-256 hash against the published `checksums.json`:
|
||||
|
||||
```bash
|
||||
@@ -669,6 +772,6 @@ fi
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See repository for details.
|
||||
GNU AGPL v3.0 or later - See repository for details.
|
||||
|
||||
Built with 📡 by the [Prompt Security](https://prompt.security) team and the agent community.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
Cz4Hx/UdUdx+ibsq4njd5NOx/0b3n5bXEKWFVY2eVrgaOGyBTojzO4KO3uiBb90cHlpRvync4tKZDhjOCh2kAg==
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "clawsec-feed",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.6",
|
||||
"description": "Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security",
|
||||
"keywords": [
|
||||
"security",
|
||||
@@ -21,6 +21,11 @@
|
||||
"required": true,
|
||||
"description": "Advisory feed skill documentation"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history for advisory feed updates"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed.json",
|
||||
"required": true,
|
||||
@@ -34,10 +39,23 @@
|
||||
"feed_url": "https://api.github.com/repos/prompt-security/ClawSec/releases?skill=clawsec-feed",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"bash",
|
||||
"curl",
|
||||
"jq"
|
||||
"jq",
|
||||
"shasum",
|
||||
"unzip"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"always": false,
|
||||
"persistence": "No local persistence or automation is created by the standalone feed package; recurring polling is handled by clawsec-suite or the operator.",
|
||||
"network_egress": "Standalone installation downloads release artifacts and optional feed updates from Prompt Security GitHub/website endpoints."
|
||||
},
|
||||
"operator_review": [
|
||||
"This package is primarily signed advisory data plus install instructions; it does not itself create cron jobs or send data outward.",
|
||||
"Verify release provenance and checksums before installing on production hosts.",
|
||||
"If you need automated polling or host-side enforcement, use clawsec-suite which owns that automation layer."
|
||||
],
|
||||
"triggers": [
|
||||
"security advisories",
|
||||
"check advisories",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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.4] - 2026-04-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved signature-related local file reads into `lib/local_file_io.ts` and kept network fetch logic isolated in `lib/signatures.ts`.
|
||||
|
||||
### Security
|
||||
|
||||
- Reduced static false-positive exfiltration signals by separating local file I/O and remote fetch code paths.
|
||||
|
||||
## [0.0.3] - 2026-03-09
|
||||
|
||||
### Security
|
||||
|
||||
- Removed runtime public-key override from host-side package signature verification; verification now always uses the pinned ClawSec key.
|
||||
- Removed unsigned-package override path in host-side verification flow.
|
||||
- Added strict package/signature path policy for signature verification (`/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads`) with absolute-path, extension, symlink, and realpath boundary checks.
|
||||
- Added policy-bound path enforcement for integrity approvals: approvals now require normalized paths that are explicitly present in non-ignored integrity policy targets.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated MCP signature verification tool docs and behavior to align with bounded path policy and pinned-key-only verification.
|
||||
- Added regression tests for signature-verification and integrity-approval hardening invariants.
|
||||
|
||||
## [0.0.2] - 2026-02-28
|
||||
|
||||
### Added
|
||||
|
||||
- Exploitability-aware advisory output in NanoClaw MCP tools (`exploitability_score`, `exploitability_rationale`).
|
||||
- Exploitability filtering (`exploitabilityScore`) for `clawsec_list_advisories`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated NanoClaw advisory sorting and pre-install safety recommendation logic to prioritize exploitability context.
|
||||
- Updated NanoClaw integration docs to match current host/container integration points (`src/ipc.ts`, `src/index.ts`) and current cache schema.
|
||||
- Removed duplicate exploitability normalization logic from MCP advisory tools and now reuse `normalizeExploitabilityScore` from `lib/risk.ts`.
|
||||
- Reused `matchesAffectedSpecifier` from `lib/advisories.ts` in MCP advisory tools to keep skill/version matching logic centralized and consistent.
|
||||
@@ -0,0 +1,327 @@
|
||||
# ClawSec for NanoClaw - Installation Guide
|
||||
|
||||
This guide shows how to add ClawSec security monitoring to your NanoClaw deployment.
|
||||
|
||||
## Overview
|
||||
|
||||
ClawSec provides security advisory monitoring for NanoClaw through:
|
||||
- **MCP Tools**: Agents can check for vulnerabilities via `clawsec_check_advisories`
|
||||
- **Advisory Feed**: Automatic monitoring of https://clawsec.prompt.security/advisories/feed.json
|
||||
- **Signature Verification**: Ed25519-signed feeds ensure integrity
|
||||
- **Exploitability Context**: Advisories include exploitability score and rationale for triage
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- NanoClaw >= 0.1.0
|
||||
- Node.js >= 18.0.0
|
||||
- Write access to NanoClaw installation directory
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Copy Skill Files
|
||||
|
||||
Copy the `clawsec-nanoclaw` skill directory to your NanoClaw installation:
|
||||
|
||||
```bash
|
||||
# From the ClawSec repository
|
||||
cp -r skills/clawsec-nanoclaw /path/to/your/nanoclaw/skills/
|
||||
```
|
||||
|
||||
### 2. Integrate MCP Tools
|
||||
|
||||
Add the ClawSec MCP tools to your NanoClaw container agent runner.
|
||||
|
||||
**File**: `container/agent-runner/src/ipc-mcp-stdio.ts`
|
||||
|
||||
```typescript
|
||||
// Add these imports at the top to register all ClawSec MCP tools:
|
||||
|
||||
// Advisory tools: clawsec_check_advisories, clawsec_check_skill_safety,
|
||||
// clawsec_list_advisories, clawsec_refresh_cache
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js';
|
||||
|
||||
// Signature verification: clawsec_verify_skill_package
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/signature-verification.js';
|
||||
|
||||
// Integrity monitoring: clawsec_check_integrity, clawsec_approve_change,
|
||||
// clawsec_integrity_status, clawsec_verify_audit
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/integrity-tools.js';
|
||||
```
|
||||
|
||||
Each file calls `server.tool()` directly to register its tools. The `server`,
|
||||
`writeIpcFile`, `TASKS_DIR`, and `groupFolder` variables must be available in
|
||||
the scope where these files are imported (they are declared as ambient globals
|
||||
in each tool file).
|
||||
|
||||
### 3. Integrate IPC Handlers
|
||||
|
||||
Add the host-side IPC handlers for ClawSec operations.
|
||||
|
||||
**File**: `src/ipc.ts`
|
||||
|
||||
```typescript
|
||||
// Add these imports at the top
|
||||
import { handleAdvisoryIpc } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
import { SkillSignatureVerifier } from '../skills/clawsec-nanoclaw/host-services/skill-signature-handler.js';
|
||||
|
||||
// Initialize these once in host startup and pass through deps
|
||||
const advisoryCacheManager = new AdvisoryCacheManager('/workspace/project/data', logger);
|
||||
const signatureVerifier = new SkillSignatureVerifier();
|
||||
|
||||
// In processTaskIpc switch:
|
||||
case 'refresh_advisory_cache':
|
||||
case 'verify_skill_signature':
|
||||
await handleAdvisoryIpc(
|
||||
data,
|
||||
{ advisoryCacheManager, signatureVerifier },
|
||||
logger,
|
||||
sourceGroup
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// existing task handling
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Start Advisory Cache Service
|
||||
|
||||
Add the advisory cache manager to your host services.
|
||||
|
||||
**File**: `src/index.ts` (or your main entry point)
|
||||
|
||||
```typescript
|
||||
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
|
||||
// Start the service when your host process starts
|
||||
async function main() {
|
||||
// ... your existing initialization ...
|
||||
|
||||
// Initialize cache manager and prime it at startup
|
||||
const advisoryCacheManager = new AdvisoryCacheManager('/workspace/project/data', logger);
|
||||
await advisoryCacheManager.initialize();
|
||||
|
||||
// Recommended refresh cadence (6h)
|
||||
setInterval(() => {
|
||||
advisoryCacheManager.refresh().catch((error) => {
|
||||
logger.error({ error }, 'Periodic advisory cache refresh failed');
|
||||
});
|
||||
}, 6 * 60 * 60 * 1000);
|
||||
|
||||
// ... rest of your startup ...
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Restart NanoClaw
|
||||
|
||||
Restart your NanoClaw instance to load the new MCP tools and services:
|
||||
|
||||
```bash
|
||||
# Stop NanoClaw
|
||||
docker-compose down
|
||||
|
||||
# Start with new configuration
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Test that ClawSec is working:
|
||||
|
||||
### 1. Check MCP Tools Available
|
||||
|
||||
From within a NanoClaw agent session, the following tools should be available:
|
||||
|
||||
**Advisory Tools** (mcp-tools/advisory-tools.ts):
|
||||
- `clawsec_check_advisories` - Scan installed skills for vulnerabilities
|
||||
- `clawsec_check_skill_safety` - Pre-installation safety check
|
||||
- `clawsec_list_advisories` - List all advisories with filtering
|
||||
- `clawsec_refresh_cache` - Request immediate advisory cache refresh
|
||||
|
||||
**Signature Verification** (mcp-tools/signature-verification.ts):
|
||||
- `clawsec_verify_skill_package` - Verify Ed25519 signature on skill packages
|
||||
- Uses pinned ClawSec public key (no runtime key override)
|
||||
- Accepts staged package/signature paths only under `/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads`
|
||||
|
||||
**Integrity Monitoring** (mcp-tools/integrity-tools.ts):
|
||||
- `clawsec_check_integrity` - Check protected files for unauthorized changes
|
||||
- `clawsec_approve_change` - Approve intentional file modification as new baseline
|
||||
- `clawsec_integrity_status` - View current baseline status
|
||||
- `clawsec_verify_audit` - Verify audit log hash chain integrity
|
||||
|
||||
### 2. Test Advisory Checking
|
||||
|
||||
Ask your NanoClaw agent:
|
||||
```
|
||||
Check if any of my installed skills have security advisories
|
||||
```
|
||||
|
||||
The agent should use the `clawsec_check_advisories` tool and report results.
|
||||
|
||||
### 3. Check Advisory Cache
|
||||
|
||||
Verify the cache file was created:
|
||||
```bash
|
||||
cat /workspace/project/data/clawsec-advisory-cache.json
|
||||
```
|
||||
|
||||
You should see:
|
||||
- `feed`: Array of advisories
|
||||
- `fetchedAt`: Timestamp of last update
|
||||
- `verified`: Should be `true`
|
||||
- `publicKeyFingerprint`: SHA-256 fingerprint of the pinned signing key
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Agent Commands
|
||||
|
||||
Once installed, your NanoClaw agents can:
|
||||
|
||||
**Check for vulnerabilities:**
|
||||
```
|
||||
Scan my installed skills for security issues
|
||||
```
|
||||
|
||||
**Pre-installation check:**
|
||||
```
|
||||
Is it safe to install skill-name@1.0.0?
|
||||
```
|
||||
|
||||
**List all advisories:**
|
||||
```
|
||||
Show me all ClawSec security advisories
|
||||
```
|
||||
|
||||
### Manual Tool Invocation
|
||||
|
||||
You can also call the MCP tools directly from agent code:
|
||||
|
||||
```typescript
|
||||
// Check all installed skills
|
||||
const result = await tools.clawsec_check_advisories({
|
||||
installRoot: '/home/node/.claude/skills'
|
||||
});
|
||||
|
||||
// Check specific skill before installation
|
||||
const safetyCheck = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'risky-skill',
|
||||
skillVersion: '1.0.0'
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Cache Location
|
||||
|
||||
Default: `/workspace/project/data/clawsec-advisory-cache.json`
|
||||
|
||||
To change, pass a different data directory path to `new AdvisoryCacheManager(dataDir, logger)`.
|
||||
|
||||
### Refresh Interval
|
||||
|
||||
Default: 6 hours
|
||||
|
||||
To change, update the `setInterval(...)` duration (in milliseconds) in host startup.
|
||||
|
||||
### Feed URL
|
||||
|
||||
Default: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
|
||||
To use a mirror or custom feed, update `FEED_URL` in `skills/clawsec-nanoclaw/host-services/advisory-cache.ts`.
|
||||
|
||||
## Platform-Specific Advisories
|
||||
|
||||
ClawSec advisories can target specific platforms:
|
||||
|
||||
- **`platforms: ["nanoclaw"]`**: Only affects NanoClaw
|
||||
- **`platforms: ["openclaw"]`**: Only affects OpenClaw/MoltBot
|
||||
- **`platforms: ["openclaw", "nanoclaw"]`**: Affects both
|
||||
- **No `platforms` field**: Applies to all platforms
|
||||
|
||||
Platform metadata is preserved in advisory records and can be filtered by your policy layer.
|
||||
|
||||
## Security
|
||||
|
||||
### Signature Verification
|
||||
|
||||
All advisory feeds are Ed25519 signed. The public key is pinned in:
|
||||
```
|
||||
skills/clawsec-nanoclaw/advisories/feed-signing-public.pem
|
||||
```
|
||||
|
||||
Feeds failing signature verification are rejected.
|
||||
|
||||
### Cache Integrity
|
||||
|
||||
The advisory cache includes:
|
||||
- Cryptographic signature of feed contents
|
||||
- Verification status
|
||||
- Timestamp of last successful fetch
|
||||
|
||||
Never manually edit the cache file - it will break signature verification.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tools Not Appearing
|
||||
|
||||
**Problem**: MCP tools not showing up in agent
|
||||
|
||||
**Solution**:
|
||||
1. Check that you added the import and registration in `ipc-mcp-stdio.ts`
|
||||
2. Restart the container
|
||||
3. Check container logs for import errors
|
||||
|
||||
### Cache Not Updating
|
||||
|
||||
**Problem**: Advisory cache is empty or stale
|
||||
|
||||
**Solution**:
|
||||
1. Check that `AdvisoryCacheManager.initialize()` is called in your host entry point
|
||||
2. Verify network access to `clawsec.prompt.security`
|
||||
3. Check host logs for fetch errors
|
||||
4. Manually trigger: `curl https://clawsec.prompt.security/advisories/feed.json`
|
||||
|
||||
### Signature Verification Failing
|
||||
|
||||
**Problem**: Cache shows `"verified": false`
|
||||
|
||||
**Solution**:
|
||||
1. Ensure public key file exists at correct path
|
||||
2. Check file permissions (should be readable)
|
||||
3. Verify feed URL is correct (not using HTTP instead of HTTPS)
|
||||
4. Check for corrupted downloads (try clearing cache and refetching)
|
||||
|
||||
### IPC Communication Issues
|
||||
|
||||
**Problem**: Tools return errors about IPC
|
||||
|
||||
**Solution**:
|
||||
1. Verify IPC handlers are registered in `src/ipc.ts`
|
||||
2. Check that IPC directory exists and is writable
|
||||
3. Ensure host process is running
|
||||
4. Check host logs for handler errors
|
||||
|
||||
## Uninstallation
|
||||
|
||||
To remove ClawSec from NanoClaw:
|
||||
|
||||
1. Remove MCP tool registration from `ipc-mcp-stdio.ts`
|
||||
2. Remove IPC handler registration from `src/ipc.ts`
|
||||
3. Remove `AdvisoryCacheManager` initialization from host entry point
|
||||
4. Delete the skill directory: `rm -rf skills/clawsec-nanoclaw`
|
||||
5. Delete the cache file: `rm /workspace/project/data/clawsec-advisory-cache.json`
|
||||
6. Restart NanoClaw
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: https://clawsec.prompt.security/
|
||||
- **Issues**: https://github.com/prompt-security/clawsec/issues
|
||||
- **Security**: security@prompt.security
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-or-later
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Open an issue or check the main ClawSec documentation.
|
||||
@@ -0,0 +1,151 @@
|
||||
# ClawSec for NanoClaw
|
||||
|
||||
ClawSec now supports NanoClaw, a containerized WhatsApp bot powered by Claude agents.
|
||||
|
||||
## What Changed
|
||||
|
||||
### Advisory Feed Monitoring
|
||||
- **NVD CVE Pipeline**: Now monitors for NanoClaw-specific keywords
|
||||
- "NanoClaw", "WhatsApp-bot", "baileys" (WhatsApp library)
|
||||
- Container-related vulnerabilities
|
||||
- **Platform Targeting**: Advisories can specify `platforms: ["nanoclaw"]` for NanoClaw-specific issues
|
||||
|
||||
### Keywords Added
|
||||
The CVE monitoring now includes:
|
||||
- `NanoClaw` - Direct product name
|
||||
- `WhatsApp-bot` - Core functionality
|
||||
- `baileys` - WhatsApp client library dependency
|
||||
|
||||
## Advisory Schema
|
||||
|
||||
Advisories now support optional `platforms` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "CVE-2026-XXXXX",
|
||||
"platforms": ["openclaw", "nanoclaw"],
|
||||
"severity": "critical",
|
||||
"type": "prompt_injection",
|
||||
"affected": ["skill-name@1.0.0"],
|
||||
"action": "Update to version 1.0.1"
|
||||
}
|
||||
```
|
||||
|
||||
**Platform values:**
|
||||
- `"openclaw"` - Affects OpenClaw/ClawdBot/MoltBot only
|
||||
- `"nanoclaw"` - Affects NanoClaw only
|
||||
- `["openclaw", "nanoclaw"]` - Affects both platforms
|
||||
- (empty/missing) - Applies to all platforms (backward compatible)
|
||||
|
||||
## ClawSec NanoClaw Skill
|
||||
|
||||
ClawSec provides a complete security skill for NanoClaw deployments:
|
||||
|
||||
**Location**: `skills/clawsec-nanoclaw/`
|
||||
|
||||
### Features
|
||||
|
||||
- **9 MCP Tools** for agents to manage security:
|
||||
- `clawsec_check_advisories` - Scan installed skills for vulnerabilities
|
||||
- `clawsec_check_skill_safety` - Pre-installation safety checks
|
||||
- `clawsec_list_advisories` - Browse advisory feed with filtering
|
||||
- `clawsec_refresh_cache` - Request immediate advisory cache refresh
|
||||
- `clawsec_verify_skill_package` - Verify Ed25519 signatures on skill packages
|
||||
- `clawsec_check_integrity` - Check protected files for unauthorized changes
|
||||
- `clawsec_approve_change` - Approve intentional file modifications
|
||||
- `clawsec_integrity_status` - View file baseline status
|
||||
- `clawsec_verify_audit` - Verify audit log hash chain
|
||||
|
||||
- **Advisory Cache Service**: Host-managed feed fetching with signature validation
|
||||
- **Signature Verification**: Ed25519-signed feeds ensure integrity
|
||||
- **Exploitability Context**: Surfaces `exploitability_score` and rationale to reduce alert fatigue
|
||||
- **IPC Communication**: Container-safe host communication
|
||||
|
||||
### Installation
|
||||
|
||||
1. Copy the skill to your NanoClaw deployment:
|
||||
```bash
|
||||
cp -r skills/clawsec-nanoclaw /path/to/nanoclaw/skills/
|
||||
```
|
||||
|
||||
2. Follow the detailed guide at `skills/clawsec-nanoclaw/INSTALL.md`
|
||||
|
||||
### Quick Integration
|
||||
|
||||
The skill integrates into three places:
|
||||
|
||||
**1. MCP Tools** (container):
|
||||
```typescript
|
||||
// container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
import '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js';
|
||||
```
|
||||
|
||||
**2. IPC Handlers** (host):
|
||||
```typescript
|
||||
// src/ipc.ts
|
||||
import { handleAdvisoryIpc } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
```
|
||||
|
||||
**3. Cache Service** (host):
|
||||
```typescript
|
||||
// src/index.ts
|
||||
import { AdvisoryCacheManager } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
```
|
||||
|
||||
### Advisory Feed
|
||||
|
||||
NanoClaw consumes the same feed as OpenClaw:
|
||||
```
|
||||
https://clawsec.prompt.security/advisories/feed.json
|
||||
```
|
||||
|
||||
The feed is Ed25519 signed and automatically fetched by the cache service.
|
||||
|
||||
## Team Credits
|
||||
|
||||
This integration was developed by a team of 8 specialized agents coordinated to adapt ClawSec for NanoClaw:
|
||||
|
||||
- **pioneer-repo-scout** - ClawSec architecture analysis
|
||||
- **pioneer-nanoclaw-scout** - NanoClaw architecture analysis
|
||||
- **architect** - Integration design and coordination
|
||||
- **advisory-specialist** - Advisory feed integration
|
||||
- **integrity-specialist** - File integrity design
|
||||
- **installer-specialist** - Signature verification implementation
|
||||
- **tester** - Test infrastructure and validation
|
||||
- **documenter** - Documentation
|
||||
|
||||
Total contribution: 3000+ lines of code and comprehensive design documents.
|
||||
|
||||
## What's Included
|
||||
|
||||
The `clawsec-nanoclaw` skill provides:
|
||||
|
||||
- **1,730 lines** of production-ready TypeScript code
|
||||
- **MCP Tools** (350 lines): Agent-facing vulnerability checking
|
||||
- **Advisory Cache** (492 lines): Automatic feed fetching and caching
|
||||
- **Signature Verification** (387 lines): Ed25519 signature validation
|
||||
- **Advisory Matching** (289 lines): Skill-to-vulnerability correlation
|
||||
- **IPC Handlers** (212 lines): Container-to-host communication
|
||||
- **Complete Documentation**: Installation guide, usage examples, troubleshooting
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned features for future releases:
|
||||
- File integrity monitoring (soul-guardian adaptation for containers)
|
||||
- Real-time advisory alerts via WebSocket
|
||||
- WhatsApp-native security alert formatting
|
||||
- Behavioral analysis and anomaly detection
|
||||
- Custom/private advisory feed support
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Skill Documentation](skills/clawsec-nanoclaw/SKILL.md) - Features and architecture
|
||||
- [Installation Guide](skills/clawsec-nanoclaw/INSTALL.md) - Detailed setup instructions
|
||||
- [ClawSec Main README](README.md) - Overall ClawSec documentation
|
||||
- [Security & Signing](../../wiki/security-signing-runbook.md) - Signature verification details
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: https://github.com/prompt-security/clawsec/issues
|
||||
- **Security**: security@prompt.security
|
||||
- NanoClaw Repository: https://github.com/qwibitai/nanoclaw
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
name: clawsec-nanoclaw
|
||||
version: 0.0.4
|
||||
description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot
|
||||
---
|
||||
|
||||
# ClawSec for NanoClaw
|
||||
|
||||
Security advisory monitoring that protects your WhatsApp bot from known vulnerabilities in skills and dependencies.
|
||||
|
||||
## Overview
|
||||
|
||||
ClawSec provides MCP tools that check installed skills against a curated feed of security advisories. It prevents installation of vulnerable skills, includes exploitability context for triage, and alerts you to issues in existing ones.
|
||||
|
||||
**Core principle:** Check before you install. Monitor what's running.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use ClawSec tools when:
|
||||
- Installing a new skill (check safety first)
|
||||
- User asks "are my skills secure?"
|
||||
- Investigating suspicious behavior
|
||||
- Regular security audits
|
||||
- After receiving security notifications
|
||||
|
||||
Do NOT use for:
|
||||
- Code review (use other tools)
|
||||
- Performance issues (different concern)
|
||||
- General debugging
|
||||
|
||||
## MCP Tools Available
|
||||
|
||||
### Pre-Installation Check
|
||||
|
||||
```typescript
|
||||
// Before installing any skill
|
||||
const safety = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'new-skill',
|
||||
skillVersion: '1.0.0' // optional
|
||||
});
|
||||
|
||||
if (!safety.safe) {
|
||||
// Show user the risks before proceeding
|
||||
console.warn(`Security issues: ${safety.advisories.map(a => a.id)}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Security Audit
|
||||
|
||||
```typescript
|
||||
// Check all installed skills (defaults to ~/.claude/skills in the container)
|
||||
const result = await tools.clawsec_check_advisories({
|
||||
installRoot: '/home/node/.claude/skills' // optional
|
||||
});
|
||||
|
||||
if (result.matches.some((m) =>
|
||||
m.advisory.severity === 'critical' || m.advisory.exploitability_score === 'high'
|
||||
)) {
|
||||
// Alert user immediately
|
||||
console.error('Urgent advisories found!');
|
||||
}
|
||||
```
|
||||
|
||||
### Browse Advisories
|
||||
|
||||
```typescript
|
||||
// List advisories with filters
|
||||
const advisories = await tools.clawsec_list_advisories({
|
||||
severity: 'high', // optional
|
||||
exploitabilityScore: 'high' // optional
|
||||
});
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Tool | Key Parameter |
|
||||
|------|------|---------------|
|
||||
| Pre-install check | `clawsec_check_skill_safety` | `skillName` |
|
||||
| Audit all skills | `clawsec_check_advisories` | `installRoot` (optional) |
|
||||
| Browse feed | `clawsec_list_advisories` | `severity`, `type`, `exploitabilityScore` (optional) |
|
||||
| Verify package signature | `clawsec_verify_skill_package` | `packagePath` |
|
||||
| Refresh advisory cache | `clawsec_refresh_cache` | (none) |
|
||||
| Check file integrity | `clawsec_check_integrity` | `mode`, `autoRestore` (optional) |
|
||||
| Approve file change | `clawsec_approve_change` | `path` |
|
||||
| View baseline status | `clawsec_integrity_status` | `path` (optional) |
|
||||
| Verify audit log | `clawsec_verify_audit` | (none) |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Safe Skill Installation
|
||||
|
||||
```typescript
|
||||
// ALWAYS check before installing
|
||||
const safety = await tools.clawsec_check_skill_safety({
|
||||
skillName: userRequestedSkill
|
||||
});
|
||||
|
||||
if (safety.safe) {
|
||||
// Proceed with installation
|
||||
await installSkill(userRequestedSkill);
|
||||
} else {
|
||||
// Show user the risks and get confirmation
|
||||
await showSecurityWarning(safety.advisories);
|
||||
if (await getUserConfirmation()) {
|
||||
await installSkill(userRequestedSkill);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Periodic Security Check
|
||||
|
||||
```typescript
|
||||
// Add to scheduled tasks
|
||||
schedule_task({
|
||||
prompt: "Check advisories using clawsec_check_advisories and alert when critical or high-exploitability matches appear",
|
||||
schedule_type: "cron",
|
||||
schedule_value: "0 9 * * *" // Daily at 9am
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 3: User Security Query
|
||||
|
||||
```
|
||||
User: "Are my skills secure?"
|
||||
|
||||
You: I'll check installed skills for known vulnerabilities.
|
||||
[Use clawsec_check_advisories]
|
||||
|
||||
Response:
|
||||
✅ No urgent issues found.
|
||||
- 2 low-severity/low-exploitability advisories
|
||||
- All skills up to date
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Installing without checking
|
||||
```typescript
|
||||
// DON'T
|
||||
await installSkill('untrusted-skill');
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO
|
||||
const safety = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'untrusted-skill'
|
||||
});
|
||||
if (safety.safe) await installSkill('untrusted-skill');
|
||||
```
|
||||
|
||||
### ❌ Ignoring exploitability context
|
||||
```typescript
|
||||
// DON'T: Use severity only
|
||||
if (advisory.severity === 'high') {
|
||||
notifyNow(advisory);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO: Use exploitability + severity
|
||||
if (
|
||||
advisory.exploitability_score === 'high' ||
|
||||
advisory.severity === 'critical'
|
||||
) {
|
||||
notifyNow(advisory);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Skipping critical severity
|
||||
```typescript
|
||||
// DON'T: Ignore high exploitability in medium severity advisories
|
||||
if (advisory.severity === 'critical') alert();
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO: Prioritize exploitability and severity together
|
||||
if (advisory.exploitability_score === 'high' || advisory.severity === 'critical') {
|
||||
// Alert immediately
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Feed Source**: https://clawsec.prompt.security/advisories/feed.json
|
||||
|
||||
**Update Frequency**: Every 6 hours (automatic)
|
||||
|
||||
**Signature Verification**: Ed25519 signed feeds
|
||||
**Package Verification Policy**: pinned key only, bounded package/signature paths
|
||||
|
||||
**Cache Location**: `/workspace/project/data/clawsec-advisory-cache.json`
|
||||
|
||||
See [INSTALL.md](./INSTALL.md) for setup and [docs/](./docs/) for advanced usage.
|
||||
|
||||
## Real-World Impact
|
||||
|
||||
- Prevents installation of skills with known RCE vulnerabilities
|
||||
- Alerts to supply chain attacks in dependencies
|
||||
- Provides actionable remediation steps
|
||||
- Zero false positives (curated feed only)
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,567 @@
|
||||
# File Integrity Monitoring for NanoClaw
|
||||
|
||||
ClawSec's file integrity monitoring protects critical NanoClaw configuration files from unauthorized modification.
|
||||
|
||||
## What It Does
|
||||
|
||||
**Protects Critical Files:**
|
||||
- `registered_groups.json` - Prevents unauthorized group access
|
||||
- `CLAUDE.md` files - Protects agent instructions
|
||||
- Container/host code - Alerts on unexpected changes
|
||||
|
||||
**How It Works:**
|
||||
1. **Baseline**: Stores SHA-256 hashes of approved file states
|
||||
2. **Monitoring**: Periodically checks files for changes (drift)
|
||||
3. **Restore**: Automatically reverts critical files to approved versions
|
||||
4. **Audit**: Maintains tamper-evident log of all operations
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Verify Installation
|
||||
|
||||
Check that integrity monitoring is available:
|
||||
|
||||
```bash
|
||||
# From container
|
||||
ls /workspace/project/skills/clawsec-nanoclaw/guardian/
|
||||
# Should show: policy.json, integrity-monitor.ts
|
||||
```
|
||||
|
||||
### Step 2: Initialize Baselines
|
||||
|
||||
The first time integrity monitoring runs, it creates baselines automatically:
|
||||
|
||||
```typescript
|
||||
// Agent calls this (happens automatically on first integrity check)
|
||||
await tools.clawsec_check_integrity();
|
||||
```
|
||||
|
||||
This creates:
|
||||
```
|
||||
/workspace/project/data/soul-guardian/
|
||||
├── baselines.json # SHA-256 hashes
|
||||
├── approved/ # File snapshots
|
||||
│ ├── registered_groups.json
|
||||
│ └── CLAUDE.md
|
||||
├── patches/ # Diffs (empty initially)
|
||||
├── quarantine/ # Tampered files (empty initially)
|
||||
└── audit.jsonl # Event log
|
||||
```
|
||||
|
||||
### Step 3: Enable Scheduled Monitoring
|
||||
|
||||
Add to main group's scheduled tasks:
|
||||
|
||||
```typescript
|
||||
schedule_task({
|
||||
prompt: `
|
||||
Check file integrity with clawsec_check_integrity.
|
||||
If drift detected and files restored, send WhatsApp message:
|
||||
"⚠️ SECURITY ALERT
|
||||
|
||||
Unauthorized changes detected and automatically reverted:
|
||||
[list files that were restored]
|
||||
|
||||
Review details: /workspace/project/data/soul-guardian/patches/"
|
||||
`,
|
||||
schedule_type: 'cron',
|
||||
schedule_value: '*/30 * * * *', // Every 30 minutes
|
||||
context_mode: 'isolated'
|
||||
});
|
||||
```
|
||||
|
||||
That's it! Integrity monitoring is now active.
|
||||
|
||||
## MCP Tools Reference
|
||||
|
||||
### 1. `clawsec_check_integrity`
|
||||
|
||||
Check all protected files for unauthorized changes.
|
||||
|
||||
**Parameters:**
|
||||
- `mode` (optional): `'check'` (default) or `'status'`
|
||||
- `check`: Detect drift and auto-restore
|
||||
- `status`: View baselines only (no drift detection)
|
||||
- `autoRestore` (optional): `true` (default) or `false`
|
||||
- If `false`, drift is detected but not auto-fixed
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"timestamp": "2026-02-25T12:00:00Z",
|
||||
"drift_detected": false,
|
||||
"files": [
|
||||
{
|
||||
"path": "/workspace/project/data/registered_groups.json",
|
||||
"status": "ok",
|
||||
"mode": "restore",
|
||||
"expected_sha": "abc123...",
|
||||
"found_sha": "abc123..."
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"ok": 3,
|
||||
"drifted": 0,
|
||||
"restored": 0,
|
||||
"alerted": 0,
|
||||
"errors": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const result = await tools.clawsec_check_integrity();
|
||||
|
||||
if (result.drift_detected) {
|
||||
console.log('⚠️ Drift detected!');
|
||||
for (const file of result.files) {
|
||||
if (file.status === 'restored') {
|
||||
console.log(`✅ Restored: ${file.path}`);
|
||||
console.log(` Diff: ${file.patch_path}`);
|
||||
} else if (file.status === 'drifted') {
|
||||
console.log(`⚠️ Changed: ${file.path} (alert only)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `clawsec_approve_change`
|
||||
|
||||
Approve an intentional file modification as the new baseline.
|
||||
|
||||
**When to use:**
|
||||
- After legitimately updating CLAUDE.md
|
||||
- After adding/removing groups in registered_groups.json
|
||||
- After any intentional change to protected files
|
||||
|
||||
**Parameters:**
|
||||
- `path` (required): Absolute path to file
|
||||
- `note` (optional): Explanation for audit log
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"path": "/workspace/group/CLAUDE.md",
|
||||
"approved_at": "2026-02-25T12:00:00Z",
|
||||
"approved_by": "agent",
|
||||
"note": "Added new skill instructions"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// After editing CLAUDE.md
|
||||
await tools.clawsec_approve_change({
|
||||
path: '/workspace/group/CLAUDE.md',
|
||||
note: 'Updated agent instructions for new skill'
|
||||
});
|
||||
|
||||
console.log('✅ Change approved - new baseline created');
|
||||
```
|
||||
|
||||
### 3. `clawsec_integrity_status`
|
||||
|
||||
View current baseline status without checking for drift.
|
||||
|
||||
**Parameters:**
|
||||
- `path` (optional): Specific file, or all if omitted
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"baseline_age": "2026-02-25T10:00:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "/workspace/project/data/registered_groups.json",
|
||||
"mode": "restore",
|
||||
"priority": "critical",
|
||||
"has_baseline": true,
|
||||
"baseline_sha": "abc123...",
|
||||
"approved_at": "2026-02-25T10:00:00Z",
|
||||
"snapshot_exists": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const status = await tools.clawsec_integrity_status();
|
||||
|
||||
console.log('Protected files:');
|
||||
for (const file of status.files) {
|
||||
console.log(`- ${file.path} (${file.mode}, ${file.priority})`);
|
||||
console.log(` Last approved: ${file.approved_at}`);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `clawsec_verify_audit`
|
||||
|
||||
Verify audit log hash chain integrity.
|
||||
|
||||
**No parameters.**
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"valid": true,
|
||||
"entries": 42,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const verification = await tools.clawsec_verify_audit();
|
||||
|
||||
if (!verification.valid) {
|
||||
console.log('🚨 CRITICAL: Audit log has been tampered with!');
|
||||
console.log('Errors:', verification.errors);
|
||||
} else {
|
||||
console.log(`✅ Audit log verified (${verification.entries} entries)`);
|
||||
}
|
||||
```
|
||||
|
||||
## Protected Files Policy
|
||||
|
||||
### Critical Priority (Auto-Restore)
|
||||
|
||||
**`/workspace/project/data/registered_groups.json`**
|
||||
- **Risk**: Tampering grants unauthorized group access
|
||||
- **Action**: Immediate auto-restore + alert
|
||||
|
||||
**`/workspace/group/CLAUDE.md`**
|
||||
- **Risk**: Modifies agent behavior
|
||||
- **Action**: Immediate auto-restore + alert
|
||||
|
||||
**`/workspace/project/groups/global/CLAUDE.md`**
|
||||
- **Risk**: Affects all groups
|
||||
- **Action**: Immediate auto-restore + alert
|
||||
|
||||
### Medium Priority (Alert Only)
|
||||
|
||||
**Container code** (`/workspace/project/container/**/*.ts`)
|
||||
- **Risk**: Unexpected code changes
|
||||
- **Action**: Alert for review (no auto-restore)
|
||||
|
||||
**Host code** (`/workspace/project/host/**/*.ts`)
|
||||
- **Risk**: Unexpected code changes
|
||||
- **Action**: Alert for review (no auto-restore)
|
||||
|
||||
### Ignored
|
||||
|
||||
**IPC files** (`/workspace/ipc/**/*`)
|
||||
- Changes are expected and frequent
|
||||
|
||||
**Conversations** (`/workspace/group/conversations/**/*`)
|
||||
- Changes are expected and frequent
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Scenario 1: Scheduled Monitoring
|
||||
|
||||
**Setup:**
|
||||
```typescript
|
||||
schedule_task({
|
||||
prompt: 'Run clawsec_check_integrity and alert on drift',
|
||||
schedule_type: 'cron',
|
||||
schedule_value: '*/30 * * * *'
|
||||
});
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Every 30 minutes, agent checks integrity
|
||||
2. If drift detected in critical files:
|
||||
- Files auto-restored to baseline
|
||||
- Tampered versions quarantined
|
||||
- Diff patch generated
|
||||
- User alerted via WhatsApp
|
||||
3. If drift in non-critical files:
|
||||
- Alert only, no auto-restore
|
||||
|
||||
### Scenario 2: Updating Agent Instructions
|
||||
|
||||
**Workflow:**
|
||||
```typescript
|
||||
// 1. Edit CLAUDE.md
|
||||
fs.writeFileSync('/workspace/group/CLAUDE.md', newInstructions);
|
||||
|
||||
// 2. Test changes
|
||||
// ... verify agent behaves correctly ...
|
||||
|
||||
// 3. Approve changes
|
||||
await tools.clawsec_approve_change({
|
||||
path: '/workspace/group/CLAUDE.md',
|
||||
note: 'Added instructions for new weather skill'
|
||||
});
|
||||
|
||||
// 4. Future integrity checks will use this new baseline
|
||||
```
|
||||
|
||||
### Scenario 3: Adding a New Group
|
||||
|
||||
**Workflow:**
|
||||
```typescript
|
||||
// 1. Add group to registered_groups.json
|
||||
const groups = JSON.parse(fs.readFileSync('/workspace/project/data/registered_groups.json'));
|
||||
groups['new-jid'] = { name: 'Family', folder: 'family', trigger: '@Andy' };
|
||||
fs.writeFileSync('/workspace/project/data/registered_groups.json', JSON.stringify(groups, null, 2));
|
||||
|
||||
// 2. Approve the change
|
||||
await tools.clawsec_approve_change({
|
||||
path: '/workspace/project/data/registered_groups.json',
|
||||
note: 'Added family group'
|
||||
});
|
||||
```
|
||||
|
||||
### Scenario 4: Investigating Drift
|
||||
|
||||
**When drift is detected:**
|
||||
```typescript
|
||||
const result = await tools.clawsec_check_integrity();
|
||||
|
||||
if (result.drift_detected) {
|
||||
for (const file of result.files) {
|
||||
if (file.status === 'restored') {
|
||||
// Critical file was auto-restored
|
||||
console.log(`🔧 Auto-restored: ${file.path}`);
|
||||
console.log(`📄 Diff: ${file.patch_path}`);
|
||||
console.log(`📦 Quarantine: ${file.quarantine_path}`);
|
||||
|
||||
// Review the diff
|
||||
const diff = fs.readFileSync(file.patch_path, 'utf-8');
|
||||
console.log('Changes that were reverted:');
|
||||
console.log(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
### Threat Model
|
||||
|
||||
**Protects Against:**
|
||||
- Unauthorized file modifications
|
||||
- Group hijacking (via registered_groups.json tampering)
|
||||
- Agent instruction poisoning (via CLAUDE.md changes)
|
||||
- Accidental file corruption
|
||||
|
||||
**Does NOT Protect Against:**
|
||||
- Attacker with full host access (can modify baselines)
|
||||
- Simultaneous baseline + file modification
|
||||
- Malicious scheduled tasks that approve their own changes
|
||||
|
||||
### Baseline Storage
|
||||
|
||||
**Location:** `/workspace/project/data/soul-guardian/`
|
||||
|
||||
**Access Control:**
|
||||
- Baselines written only by host process
|
||||
- Containers access via IPC only
|
||||
- No container can modify its own baselines
|
||||
|
||||
**Integrity:**
|
||||
- SHA-256 hashes (industry standard)
|
||||
- Hash-chained audit log (tamper-evident)
|
||||
- Atomic file operations (safe restores)
|
||||
|
||||
### Audit Log
|
||||
|
||||
**Format:** JSONL with hash chaining
|
||||
|
||||
**Each entry includes:**
|
||||
```json
|
||||
{
|
||||
"ts": "2026-02-25T12:00:00Z",
|
||||
"event": "drift",
|
||||
"actor": "agent",
|
||||
"path": "/workspace/group/CLAUDE.md",
|
||||
"expected_sha": "abc123...",
|
||||
"found_sha": "def456...",
|
||||
"chain": {
|
||||
"prev": "previous_entry_hash",
|
||||
"hash": "this_entry_hash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Chain calculation:**
|
||||
```
|
||||
hash = SHA-256(prev_hash + '\n' + canonical_json(entry_without_chain))
|
||||
```
|
||||
|
||||
This makes tampering detectable: changing any entry breaks the chain.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Integrity Check Fails
|
||||
|
||||
**Symptom:** `clawsec_check_integrity` returns `success: false`
|
||||
|
||||
**Causes:**
|
||||
1. IntegrityService not initialized
|
||||
2. Policy file missing
|
||||
3. Baselines corrupted
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check service status
|
||||
ls /workspace/project/data/soul-guardian/
|
||||
|
||||
# If missing, reinitialize
|
||||
rm -rf /workspace/project/data/soul-guardian/
|
||||
# Next integrity check will recreate baselines
|
||||
```
|
||||
|
||||
### False Positives (Legitimate Changes Flagged)
|
||||
|
||||
**Symptom:** File keeps getting restored even though changes are legitimate
|
||||
|
||||
**Cause:** Baseline not updated after intentional changes
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
await tools.clawsec_approve_change({
|
||||
path: '/path/to/file',
|
||||
note: 'Legitimate change'
|
||||
});
|
||||
```
|
||||
|
||||
### Audit Chain Broken
|
||||
|
||||
**Symptom:** `clawsec_verify_audit` returns `valid: false`
|
||||
|
||||
**Causes:**
|
||||
1. Audit log manually edited
|
||||
2. Filesystem corruption
|
||||
3. Security breach
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
const verification = await tools.clawsec_verify_audit();
|
||||
console.log('Errors:', verification.errors);
|
||||
|
||||
// If corruption, backup and reset
|
||||
cp /workspace/project/data/soul-guardian/audit.jsonl /tmp/audit-backup.jsonl
|
||||
rm /workspace/project/data/soul-guardian/audit.jsonl
|
||||
// Audit log will restart on next operation
|
||||
```
|
||||
|
||||
### High Disk Usage
|
||||
|
||||
**Symptom:** `/workspace/project/data/soul-guardian/` grows large
|
||||
|
||||
**Causes:**
|
||||
- Many drift events generate patches
|
||||
- Quarantine files accumulate
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Clean old patches (older than 30 days)
|
||||
find /workspace/project/data/soul-guardian/patches/ -mtime +30 -delete
|
||||
|
||||
# Clean quarantine (after review)
|
||||
rm /workspace/project/data/soul-guardian/quarantine/*
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
**Overhead:**
|
||||
- Baseline check: ~10ms per file
|
||||
- SHA-256 computation: ~1ms per KB
|
||||
- Restore operation: ~20ms per file
|
||||
|
||||
**Typical deployment:**
|
||||
- 3-5 protected files
|
||||
- 30-minute check interval
|
||||
- < 0.1% CPU usage
|
||||
- < 5MB disk usage
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Custom Policy
|
||||
|
||||
While the default policy is pinned by the skill, you can fork it:
|
||||
|
||||
```bash
|
||||
cp /workspace/project/skills/clawsec-nanoclaw/guardian/policy.json /workspace/project/data/custom-policy.json
|
||||
```
|
||||
|
||||
Edit and reinitialize:
|
||||
```typescript
|
||||
// Update IntegrityMonitor initialization
|
||||
new IntegrityMonitor({
|
||||
policyPath: '/workspace/project/data/custom-policy.json',
|
||||
stateDir: '/workspace/project/data/soul-guardian'
|
||||
});
|
||||
```
|
||||
|
||||
### Manual Baseline Export
|
||||
|
||||
```bash
|
||||
# Export current baselines
|
||||
cp /workspace/project/data/soul-guardian/baselines.json /tmp/baselines-backup.json
|
||||
|
||||
# Export approved snapshots
|
||||
tar -czf /tmp/approved-snapshots.tar.gz /workspace/project/data/soul-guardian/approved/
|
||||
```
|
||||
|
||||
### Baseline Import (Disaster Recovery)
|
||||
|
||||
```bash
|
||||
# Restore baselines
|
||||
cp /tmp/baselines-backup.json /workspace/project/data/soul-guardian/baselines.json
|
||||
|
||||
# Restore snapshots
|
||||
tar -xzf /tmp/approved-snapshots.tar.gz -C /workspace/project/data/soul-guardian/
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I disable auto-restore for testing?**
|
||||
|
||||
A: Yes, use `autoRestore: false`:
|
||||
```typescript
|
||||
await tools.clawsec_check_integrity({ autoRestore: false });
|
||||
```
|
||||
|
||||
**Q: How do I protect additional files?**
|
||||
|
||||
A: Edit `policy.json` and add targets:
|
||||
```json
|
||||
{
|
||||
"path": "/workspace/group/my-config.json",
|
||||
"mode": "restore",
|
||||
"priority": "high",
|
||||
"description": "My custom config"
|
||||
}
|
||||
```
|
||||
|
||||
**Q: What happens if both baseline and file are modified?**
|
||||
|
||||
A: The most recent baseline wins. Always approve legitimate changes immediately.
|
||||
|
||||
**Q: Can I run integrity checks on-demand?**
|
||||
|
||||
A: Yes, just call `clawsec_check_integrity` from any agent.
|
||||
|
||||
**Q: Is the audit log encrypted?**
|
||||
|
||||
A: No, but it's hash-chained for tamper detection. Encryption can be added in Phase 3.
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: https://clawsec.prompt.security/
|
||||
- **Issues**: https://github.com/prompt-security/clawsec/issues
|
||||
- **Security Reports**: security@prompt.security
|
||||
|
||||
---
|
||||
|
||||
**Ready to protect your NanoClaw deployment? Start with the [Quick Start](#quick-start) guide above.**
|
||||
@@ -0,0 +1,488 @@
|
||||
# Skill Package Signing and Verification
|
||||
|
||||
This document explains how ClawSec signs skill packages and how NanoClaw agents verify signatures before installation.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [For Skill Publishers: How to Sign Packages](#for-skill-publishers-how-to-sign-packages)
|
||||
3. [For NanoClaw Agents: How to Verify Signatures](#for-nanoclaw-agents-how-to-verify-signatures)
|
||||
4. [Security Properties](#security-properties)
|
||||
5. [Key Management](#key-management)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Skill signature verification prevents **supply chain attacks** by ensuring skill packages haven't been tampered with during distribution. ClawSec uses **Ed25519 digital signatures** to sign skill packages, and NanoClaw agents verify these signatures before installation.
|
||||
|
||||
### Why Signature Verification?
|
||||
|
||||
Without signature verification, an attacker could:
|
||||
- **Replace** a legitimate skill package with a malicious one during download
|
||||
- **Modify** package contents to inject backdoors or steal data
|
||||
- **Distribute** trojan skills that appear legitimate but contain malware
|
||||
|
||||
Signature verification ensures:
|
||||
- ✅ **Authenticity**: Package comes from ClawSec (or trusted publisher)
|
||||
- ✅ **Integrity**: Package hasn't been modified since signing
|
||||
- ✅ **Non-repudiation**: Signer can't deny signing the package
|
||||
|
||||
---
|
||||
|
||||
## For Skill Publishers: How to Sign Packages
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- OpenSSL 1.1.1+ (for Ed25519 support)
|
||||
- Private Ed25519 signing key (generate once, keep secure)
|
||||
- Skill package ready for distribution
|
||||
|
||||
### Step 1: Generate Ed25519 Keypair (One-Time Setup)
|
||||
|
||||
```bash
|
||||
# Generate private key (KEEP THIS SECRET!)
|
||||
openssl genpkey -algorithm ED25519 -out clawsec-signing-private.pem
|
||||
|
||||
# Extract public key (share this with users)
|
||||
openssl pkey -in clawsec-signing-private.pem -pubout -out clawsec-signing-public.pem
|
||||
|
||||
# Secure the private key
|
||||
chmod 600 clawsec-signing-private.pem
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL**: Never commit the private key to version control! Store it securely:
|
||||
- Local machine: `~/.ssh/clawsec-signing-private.pem` with `chmod 600`
|
||||
- CI/CD: GitHub Secrets, AWS Secrets Manager, or similar
|
||||
- Team: 1Password, Vault, or hardware security module (HSM)
|
||||
|
||||
### Step 2: Package Your Skill
|
||||
|
||||
```bash
|
||||
# Create skill package (tarball or zip)
|
||||
tar -czf my-skill-1.0.0.tar.gz -C skills/my-skill .
|
||||
|
||||
# Or as a zip file
|
||||
zip -r my-skill-1.0.0.zip skills/my-skill/
|
||||
```
|
||||
|
||||
### Step 3: Sign the Package
|
||||
|
||||
```bash
|
||||
# Create detached Ed25519 signature
|
||||
openssl dgst -sha512 -sign clawsec-signing-private.pem \
|
||||
-out my-skill-1.0.0.tar.gz.sig \
|
||||
my-skill-1.0.0.tar.gz
|
||||
|
||||
# Verify the signature was created
|
||||
ls -lh my-skill-1.0.0.tar.gz.sig
|
||||
# Should show a ~64-byte file
|
||||
```
|
||||
|
||||
**Signature Format**: Detached Ed25519 signature, base64-encoded, stored in `.sig` file.
|
||||
|
||||
### Step 4: Distribute Package + Signature
|
||||
|
||||
Distribute **both** files together:
|
||||
- `my-skill-1.0.0.tar.gz` (the skill package)
|
||||
- `my-skill-1.0.0.tar.gz.sig` (the signature)
|
||||
|
||||
Users will verify the signature against your public key before installation.
|
||||
|
||||
### Step 5: Publish Public Key
|
||||
|
||||
Share your public key with users via:
|
||||
- **Pinned in repository**: Commit `clawsec-signing-public.pem` to your repo
|
||||
- **Website**: Host at `https://yoursite.com/clawsec-signing-public.pem`
|
||||
- **DNS TXT record**: Publish as base64-encoded TXT record
|
||||
- **Skill metadata**: Embed in `skill.json`
|
||||
|
||||
---
|
||||
|
||||
## For NanoClaw Agents: How to Verify Signatures
|
||||
|
||||
### Quick Start
|
||||
|
||||
```typescript
|
||||
// Verify a downloaded skill package before installation
|
||||
const verification = await tools.clawsec_verify_skill_package({
|
||||
packagePath: '/tmp/my-skill-1.0.0.tar.gz'
|
||||
// signaturePath auto-detected as /tmp/my-skill-1.0.0.tar.gz.sig
|
||||
});
|
||||
|
||||
const result = JSON.parse(verification.content[0].text);
|
||||
|
||||
if (!result.valid) {
|
||||
console.log('⚠️ SIGNATURE VERIFICATION FAILED!');
|
||||
console.log(`Reason: ${result.reason || result.error}`);
|
||||
console.log('DO NOT install this package.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✓ Signature valid (signer: ${result.signer})`);
|
||||
console.log(`Package hash: ${result.packageInfo.sha256}`);
|
||||
console.log('Safe to proceed with installation.');
|
||||
```
|
||||
|
||||
### MCP Tool: `clawsec_verify_skill_package`
|
||||
|
||||
**Parameters:**
|
||||
- `packagePath` (required): Absolute path to skill package (`.tar.gz`, `.tar`, `.tgz`, or `.zip`)
|
||||
- `signaturePath` (optional): Path to signature file (auto-detects `.sig` if omitted)
|
||||
|
||||
Path policy:
|
||||
- Files must be under one of: `/tmp`, `/var/tmp`, `/workspace/ipc`, `/workspace/project/data`, `/workspace/project/tmp`, `/workspace/project/downloads`
|
||||
- Symlinks are rejected
|
||||
- Signatures must use `.sig`
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
{
|
||||
success: boolean, // Operation completed without errors
|
||||
valid: boolean, // Signature is cryptographically valid
|
||||
recommendation: string, // "install" | "block" | "review"
|
||||
signer: string, // "clawsec"
|
||||
algorithm: "Ed25519", // Signature algorithm
|
||||
verifiedAt: string, // ISO timestamp
|
||||
packageInfo: {
|
||||
size: number, // Package file size in bytes
|
||||
sha256: string // SHA-256 hash of package
|
||||
},
|
||||
error?: string // Error message if failed
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Patterns
|
||||
|
||||
#### Pattern 1: Basic Pre-Installation Check
|
||||
|
||||
```typescript
|
||||
async function installSkill(packagePath: string) {
|
||||
// Verify signature first
|
||||
const verification = await tools.clawsec_verify_skill_package({ packagePath });
|
||||
const result = JSON.parse(verification.content[0].text);
|
||||
|
||||
if (result.recommendation === 'block') {
|
||||
throw new Error(`Cannot install: ${result.reason || result.error}`);
|
||||
}
|
||||
|
||||
// Signature valid - proceed with extraction
|
||||
extractPackage(packagePath, '/workspace/project/skills/');
|
||||
}
|
||||
```
|
||||
|
||||
#### Pattern 2: Combined Security Checks
|
||||
|
||||
```typescript
|
||||
async function installSkillSafely(packagePath: string, skillName: string) {
|
||||
// Step 1: Verify signature
|
||||
const sigVerify = await tools.clawsec_verify_skill_package({ packagePath });
|
||||
const sigResult = JSON.parse(sigVerify.content[0].text);
|
||||
|
||||
if (!sigResult.valid) {
|
||||
throw new Error(`Signature invalid: ${sigResult.reason}`);
|
||||
}
|
||||
|
||||
// Step 2: Check advisories
|
||||
const advisory = await tools.clawsec_check_skill_safety({ skillName });
|
||||
const advResult = JSON.parse(advisory.content[0].text);
|
||||
|
||||
if (!advResult.safe) {
|
||||
throw new Error(`Known vulnerabilities: ${advResult.advisories.map(a => a.id).join(', ')}`);
|
||||
}
|
||||
|
||||
// Both checks passed - safe to install
|
||||
extractPackage(packagePath, '/workspace/project/skills/');
|
||||
console.log(`✓ Installed ${skillName} (verified + no advisories)`);
|
||||
}
|
||||
```
|
||||
|
||||
#### Pattern 3: Download and Verify Workflow
|
||||
|
||||
```typescript
|
||||
async function downloadAndInstallSkill(url: string) {
|
||||
const packagePath = `/tmp/${Date.now()}-skill.tar.gz`;
|
||||
const signaturePath = `${packagePath}.sig`;
|
||||
|
||||
// Download package
|
||||
await fetch(url).then(r => r.arrayBuffer()).then(buf => {
|
||||
fs.writeFileSync(packagePath, Buffer.from(buf));
|
||||
});
|
||||
|
||||
// Download signature
|
||||
await fetch(`${url}.sig`).then(r => r.text()).then(sig => {
|
||||
fs.writeFileSync(signaturePath, sig);
|
||||
});
|
||||
|
||||
// Verify before installation
|
||||
const verification = await tools.clawsec_verify_skill_package({
|
||||
packagePath,
|
||||
signaturePath
|
||||
});
|
||||
|
||||
const result = JSON.parse(verification.content[0].text);
|
||||
|
||||
if (!result.valid) {
|
||||
fs.unlinkSync(packagePath); // Delete tampered file
|
||||
fs.unlinkSync(signaturePath);
|
||||
throw new Error('Signature verification failed');
|
||||
}
|
||||
|
||||
// Install verified package
|
||||
extractPackage(packagePath, '/workspace/project/skills/');
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(packagePath);
|
||||
fs.unlinkSync(signaturePath);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
const verification = await tools.clawsec_verify_skill_package({ packagePath });
|
||||
const result = JSON.parse(verification.content[0].text);
|
||||
|
||||
// Check result.success first (operation completed)
|
||||
if (!result.success) {
|
||||
console.error('Verification operation failed:', result.error);
|
||||
// Reasons: file not found, service unavailable, timeout
|
||||
return;
|
||||
}
|
||||
|
||||
// Then check result.valid (signature cryptographically valid)
|
||||
if (!result.valid) {
|
||||
console.error('Invalid signature:', result.reason);
|
||||
// Reasons: signature mismatch, tampered package, invalid format
|
||||
return;
|
||||
}
|
||||
|
||||
// Finally check recommendation
|
||||
switch (result.recommendation) {
|
||||
case 'install':
|
||||
console.log('✓ Safe to install');
|
||||
break;
|
||||
case 'block':
|
||||
console.error('⛔ Installation blocked');
|
||||
break;
|
||||
case 'review':
|
||||
console.warn('⚠️ Manual review recommended');
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Properties
|
||||
|
||||
### What Signature Verification Prevents
|
||||
|
||||
✅ **Prevents:**
|
||||
- **Tampering**: Detecting if package contents were modified after signing
|
||||
- **MITM attacks**: Detecting if package was swapped during download
|
||||
- **Malicious mirrors**: Ensuring package comes from trusted source
|
||||
- **Accidental corruption**: Detecting file corruption during transfer
|
||||
|
||||
### What Signature Verification Does NOT Prevent
|
||||
|
||||
❌ **Does Not Prevent:**
|
||||
- **Malicious signed packages**: If the publisher's key is compromised
|
||||
- **Zero-day vulnerabilities**: Bugs unknown to the publisher
|
||||
- **Social engineering**: Convincing users to trust malicious publishers
|
||||
- **Time-of-check-to-time-of-use**: Package modified after verification
|
||||
|
||||
**Defense in Depth**: Combine signature verification with:
|
||||
1. **Advisory checking** (`clawsec_check_skill_safety`)
|
||||
2. **Code review** (manual inspection of skill code)
|
||||
3. **Sandboxing** (run skills in isolated containers)
|
||||
4. **Monitoring** (detect suspicious behavior at runtime)
|
||||
|
||||
### Trust Model
|
||||
|
||||
Signature verification relies on **trust in the public key**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ You trust ClawSec's public key │
|
||||
│ ↓ │
|
||||
│ ClawSec signs package with private key │
|
||||
│ ↓ │
|
||||
│ You verify signature with ClawSec's public key │
|
||||
│ ↓ │
|
||||
│ Signature valid → Package is authentic │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Question**: How do you establish trust in the public key?
|
||||
- **Pinned in repository**: Public key committed to ClawSec repo (trust GitHub)
|
||||
- **HTTPS website**: Download from `https://clawsec.prompt.security/` (trust TLS/CA)
|
||||
- **Out-of-band verification**: Compare key fingerprint via phone, Signal, etc.
|
||||
- **Web of Trust**: Multiple trusted sources publish the same key
|
||||
|
||||
---
|
||||
|
||||
## Key Management
|
||||
|
||||
### ClawSec's Pinned Public Key
|
||||
|
||||
**Location**: `/workspace/project/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem`
|
||||
|
||||
This is the **same key** used for advisory feed verification, providing a single trust anchor for all ClawSec security operations.
|
||||
|
||||
**Key Fingerprint** (for manual verification):
|
||||
```bash
|
||||
# Compute fingerprint of pinned key
|
||||
openssl pkey -pubin -in feed-signing-public.pem -outform DER | \
|
||||
openssl dgst -sha256 -binary | base64
|
||||
# Expected: <will be filled in after key generation>
|
||||
```
|
||||
|
||||
### Public Key Policy
|
||||
|
||||
The verifier always uses the pinned ClawSec public key from this skill package.
|
||||
Runtime public-key overrides are intentionally not supported.
|
||||
|
||||
### Key Rotation
|
||||
|
||||
If ClawSec's signing key is compromised or needs rotation:
|
||||
|
||||
1. **Generate new keypair** (keep private key secure)
|
||||
2. **Sign all packages** with new key
|
||||
3. **Publish new public key** to all distribution channels
|
||||
4. **Update pinned key** in `/workspace/project/skills/clawsec-nanoclaw/advisories/`
|
||||
5. **Deprecate old key** after transition period (e.g., 90 days)
|
||||
|
||||
During transition, support **dual signatures**:
|
||||
- `package.tar.gz.sig` (old key)
|
||||
- `package.tar.gz.sig2` (new key)
|
||||
|
||||
Agents can verify with either key during the overlap period.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "Signature file not found"
|
||||
|
||||
**Cause**: Missing `.sig` file or incorrect path.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check if signature exists
|
||||
ls -l /tmp/skill.tar.gz.sig
|
||||
|
||||
# If missing, download signature
|
||||
curl -o /tmp/skill.tar.gz.sig https://example.com/skill.tar.gz.sig
|
||||
|
||||
# Or specify explicit path
|
||||
clawsec_verify_skill_package({
|
||||
packagePath: '/tmp/skill.tar.gz',
|
||||
signaturePath: '/tmp/custom-signature.sig'
|
||||
})
|
||||
```
|
||||
|
||||
### Error: "Signature verification failed"
|
||||
|
||||
**Cause**: Package was tampered with, or signature doesn't match package.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Re-download package and signature
|
||||
curl -o /tmp/skill.tar.gz https://example.com/skill.tar.gz
|
||||
curl -o /tmp/skill.tar.gz.sig https://example.com/skill.tar.gz.sig
|
||||
|
||||
# Verify manually with OpenSSL
|
||||
openssl dgst -sha512 -verify clawsec-signing-public.pem \
|
||||
-signature /tmp/skill.tar.gz.sig /tmp/skill.tar.gz
|
||||
# Should output: "Verified OK"
|
||||
```
|
||||
|
||||
### Error: "Invalid PEM format"
|
||||
|
||||
**Cause**: Public key file is corrupted or not in PEM format.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check public key format
|
||||
head -1 /path/to/public-key.pem
|
||||
# Should output: "-----BEGIN PUBLIC KEY-----"
|
||||
|
||||
# Re-download public key
|
||||
curl -o clawsec-signing-public.pem \
|
||||
https://clawsec.prompt.security/clawsec-signing-public.pem
|
||||
```
|
||||
|
||||
### Error: "Package file not found"
|
||||
|
||||
**Cause**: Incorrect path or file doesn't exist.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Use absolute paths (required)
|
||||
clawsec_verify_skill_package({
|
||||
packagePath: '/tmp/skill.tar.gz' // ✓ Absolute
|
||||
// packagePath: './skill.tar.gz' // ✗ Relative (won't work)
|
||||
})
|
||||
|
||||
# Verify file exists
|
||||
stat /tmp/skill.tar.gz
|
||||
```
|
||||
|
||||
### Verification Times Out (>5s)
|
||||
|
||||
**Cause**: Large package (>50MB) or slow disk I/O.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check package size
|
||||
ls -lh /tmp/skill.tar.gz
|
||||
|
||||
# For very large packages, verification can take time
|
||||
# Consider splitting into smaller skill modules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Signature File Format
|
||||
|
||||
ClawSec uses **Ed25519 detached signatures** in raw binary format, base64-encoded.
|
||||
|
||||
**File Structure**:
|
||||
```
|
||||
my-skill-1.0.0.tar.gz.sig:
|
||||
Line 1: base64-encoded signature (88 characters)
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
MEQCIDxyz...ABC123==
|
||||
```
|
||||
|
||||
**Properties**:
|
||||
- Algorithm: Ed25519 (EdDSA with Curve25519)
|
||||
- Signature size: 64 bytes (88 characters base64)
|
||||
- Hash function: SHA-512 (internal to Ed25519)
|
||||
- Format: Raw binary, base64-encoded
|
||||
|
||||
**Verification Algorithm**:
|
||||
1. Decode base64 signature → 64-byte binary
|
||||
2. Hash package with SHA-512
|
||||
3. Verify Ed25519 signature(hash, publicKey) → boolean
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Ed25519 Specification (RFC 8032)](https://tools.ietf.org/html/rfc8032)
|
||||
- [OpenSSL Ed25519 Documentation](https://www.openssl.org/docs/man3.0/man7/Ed25519.html)
|
||||
- [ClawSec Security Architecture](https://clawsec.prompt.security/docs/architecture)
|
||||
- [Supply Chain Attack Prevention](https://owasp.org/www-community/attacks/Supply_Chain_Attack)
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0.0
|
||||
**Last Updated**: 2026-02-25
|
||||
**Maintainer**: ClawSec Security Team
|
||||
@@ -0,0 +1,736 @@
|
||||
/**
|
||||
* File Integrity Monitor for NanoClaw
|
||||
*
|
||||
* TypeScript port of ClawSec's soul-guardian with NanoClaw-specific adaptations.
|
||||
*
|
||||
* Key Features:
|
||||
* - SHA-256 baseline tracking for protected files
|
||||
* - Drift detection with unified diff generation
|
||||
* - Auto-restore for critical files (with quarantine)
|
||||
* - Hash-chained tamper-evident audit log
|
||||
* - Per-file policy (restore/alert/ignore modes)
|
||||
*
|
||||
* Security Model:
|
||||
* - Baselines stored on host only (containers access via IPC)
|
||||
* - Atomic file operations for restores
|
||||
* - Refuses to operate on symlinks
|
||||
* - Hash-chained audit log prevents tampering
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
// glob is available when running in the NanoClaw host environment.
|
||||
// For type checking in the clawsec repo, we declare a minimal interface.
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
declare namespace glob {
|
||||
function sync(pattern: string, options?: { nodir?: boolean }): string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PolicyTarget {
|
||||
path?: string;
|
||||
pattern?: string;
|
||||
mode: 'restore' | 'alert' | 'ignore';
|
||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
version: number;
|
||||
description: string;
|
||||
nanoclaw_version: string;
|
||||
targets: PolicyTarget[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface FileBaseline {
|
||||
sha256: string;
|
||||
approved_at: string;
|
||||
approved_by: string;
|
||||
mode: 'restore' | 'alert' | 'ignore';
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface BaselinesManifest {
|
||||
schema_version: string;
|
||||
algorithm: 'sha256';
|
||||
created_at: string;
|
||||
files: Record<string, FileBaseline>;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
ts: string;
|
||||
event: 'init' | 'drift' | 'restore' | 'approve' | 'error';
|
||||
actor: string;
|
||||
note?: string;
|
||||
path: string;
|
||||
mode?: string;
|
||||
expected_sha?: string;
|
||||
found_sha?: string;
|
||||
patch_path?: string;
|
||||
quarantine_path?: string;
|
||||
error?: string;
|
||||
chain?: {
|
||||
prev: string;
|
||||
hash: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DriftedFile {
|
||||
path: string;
|
||||
mode: 'restore' | 'alert';
|
||||
expected_sha: string;
|
||||
found_sha: string;
|
||||
patch_path: string;
|
||||
restored: boolean;
|
||||
quarantine_path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
success: boolean;
|
||||
timestamp: string;
|
||||
drift_detected: boolean;
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: 'ok' | 'drifted' | 'restored' | 'error';
|
||||
mode: string;
|
||||
expected_sha?: string;
|
||||
found_sha?: string;
|
||||
patch_path?: string;
|
||||
quarantine_path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
ok: number;
|
||||
drifted: number;
|
||||
restored: number;
|
||||
alerted: number;
|
||||
errors: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IntegrityMonitorOptions {
|
||||
policyPath: string;
|
||||
stateDir: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const CHAIN_GENESIS = '0'.repeat(64);
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
function utcNowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sha256Hex(data: Buffer | string): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(data);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function sha256File(filePath: string): string {
|
||||
const data = fs.readFileSync(filePath);
|
||||
return sha256Hex(data);
|
||||
}
|
||||
|
||||
function isSymlink(filePath: string): boolean {
|
||||
try {
|
||||
const stats = fs.lstatSync(filePath);
|
||||
return stats.isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function refuseSymlink(filePath: string): void {
|
||||
if (isSymlink(filePath)) {
|
||||
throw new Error(`Refusing to operate on symlink: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(dirPath: string): void {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function atomicWrite(filePath: string, data: string | Buffer): void {
|
||||
ensureDir(path.dirname(filePath));
|
||||
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
||||
fs.writeFileSync(tmpPath, data);
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
||||
function unifiedDiff(oldText: string, newText: string, oldLabel: string, newLabel: string): string {
|
||||
// Simple unified diff implementation
|
||||
const oldLines = oldText.split('\n');
|
||||
const newLines = newText.split('\n');
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`--- ${oldLabel}`);
|
||||
lines.push(`+++ ${newLabel}`);
|
||||
lines.push(`@@ -1,${oldLines.length} +1,${newLines.length} @@`);
|
||||
|
||||
for (let i = 0; i < Math.max(oldLines.length, newLines.length); i++) {
|
||||
if (i < oldLines.length && i < newLines.length) {
|
||||
if (oldLines[i] !== newLines[i]) {
|
||||
lines.push(`-${oldLines[i]}`);
|
||||
lines.push(`+${newLines[i]}`);
|
||||
} else {
|
||||
lines.push(` ${oldLines[i]}`);
|
||||
}
|
||||
} else if (i < oldLines.length) {
|
||||
lines.push(`-${oldLines[i]}`);
|
||||
} else {
|
||||
lines.push(`+${newLines[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function safePatchTag(tag: string): string {
|
||||
return tag.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40) || 'patch';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integrity Monitor Class
|
||||
// ============================================================================
|
||||
|
||||
export class IntegrityMonitor {
|
||||
private policyPath: string;
|
||||
private stateDir: string;
|
||||
private baselinesPath: string;
|
||||
private auditPath: string;
|
||||
private approvedDir: string;
|
||||
private patchesDir: string;
|
||||
private quarantineDir: string;
|
||||
|
||||
private policy: Policy | null = null;
|
||||
private baselines: BaselinesManifest | null = null;
|
||||
|
||||
constructor(options: IntegrityMonitorOptions) {
|
||||
this.policyPath = options.policyPath;
|
||||
this.stateDir = options.stateDir;
|
||||
this.baselinesPath = path.join(this.stateDir, 'baselines.json');
|
||||
this.auditPath = path.join(this.stateDir, 'audit.jsonl');
|
||||
this.approvedDir = path.join(this.stateDir, 'approved');
|
||||
this.patchesDir = path.join(this.stateDir, 'patches');
|
||||
this.quarantineDir = path.join(this.stateDir, 'quarantine');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
async init(actor: string = 'system', note: string = 'initial baseline'): Promise<void> {
|
||||
ensureDir(this.stateDir);
|
||||
ensureDir(this.approvedDir);
|
||||
ensureDir(this.patchesDir);
|
||||
ensureDir(this.quarantineDir);
|
||||
|
||||
// Load policy
|
||||
this.policy = this.loadPolicy();
|
||||
|
||||
// Load or create baselines
|
||||
this.baselines = this.loadBaselines();
|
||||
|
||||
// Resolve targets and initialize missing baselines
|
||||
const targets = this.resolveTargets();
|
||||
let initialized = false;
|
||||
|
||||
for (const target of targets) {
|
||||
if (target.mode === 'ignore') continue;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(target.path)) continue;
|
||||
|
||||
refuseSymlink(target.path);
|
||||
|
||||
// Check if already has baseline
|
||||
if (this.baselines.files[target.path]) continue;
|
||||
|
||||
// Create baseline
|
||||
const sha = sha256File(target.path);
|
||||
const snapshot = path.join(this.approvedDir, path.basename(target.path));
|
||||
fs.copyFileSync(target.path, snapshot);
|
||||
|
||||
this.baselines.files[target.path] = {
|
||||
sha256: sha,
|
||||
approved_at: utcNowIso(),
|
||||
approved_by: actor,
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
};
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'init',
|
||||
actor,
|
||||
note,
|
||||
path: target.path,
|
||||
mode: target.mode,
|
||||
expected_sha: sha
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize baseline for ${target.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (initialized) {
|
||||
this.saveBaselines();
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Policy Management
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private loadPolicy(): Policy {
|
||||
const raw = fs.readFileSync(this.policyPath, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
private resolveTargets(): Array<{ path: string; mode: 'restore' | 'alert' | 'ignore'; priority: string }> {
|
||||
if (!this.policy) throw new Error('Policy not loaded');
|
||||
|
||||
const targets: Array<{ path: string; mode: 'restore' | 'alert' | 'ignore'; priority: string }> = [];
|
||||
|
||||
for (const target of this.policy.targets) {
|
||||
if (target.path) {
|
||||
// Direct path
|
||||
targets.push({
|
||||
path: path.resolve(target.path),
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
});
|
||||
} else if (target.pattern) {
|
||||
// Glob pattern
|
||||
try {
|
||||
const matches = glob.sync(target.pattern, { nodir: true });
|
||||
for (const match of matches) {
|
||||
targets.push({
|
||||
path: path.resolve(match),
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to expand pattern ${target.pattern}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private normalizeBaselines(manifest: BaselinesManifest): BaselinesManifest {
|
||||
const normalizedFiles: Record<string, FileBaseline> = {};
|
||||
for (const [filePath, baseline] of Object.entries(manifest.files || {})) {
|
||||
normalizedFiles[path.resolve(filePath)] = baseline;
|
||||
}
|
||||
|
||||
return {
|
||||
...manifest,
|
||||
files: normalizedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Baseline Management
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private loadBaselines(): BaselinesManifest {
|
||||
if (fs.existsSync(this.baselinesPath)) {
|
||||
const raw = fs.readFileSync(this.baselinesPath, 'utf-8');
|
||||
return this.normalizeBaselines(JSON.parse(raw));
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: '1',
|
||||
algorithm: 'sha256',
|
||||
created_at: utcNowIso(),
|
||||
files: {}
|
||||
};
|
||||
}
|
||||
|
||||
private saveBaselines(): void {
|
||||
const data = JSON.stringify(this.baselines, null, 2);
|
||||
atomicWrite(this.baselinesPath, data);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Audit Log with Hash Chaining
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private getLastAuditHash(): string {
|
||||
if (!fs.existsSync(this.auditPath)) {
|
||||
return CHAIN_GENESIS;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.auditPath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(l => l.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return CHAIN_GENESIS;
|
||||
}
|
||||
|
||||
try {
|
||||
const lastEntry = JSON.parse(lines[lines.length - 1]);
|
||||
return lastEntry.chain?.hash || CHAIN_GENESIS;
|
||||
} catch {
|
||||
return CHAIN_GENESIS;
|
||||
}
|
||||
}
|
||||
|
||||
private appendAudit(entry: Omit<AuditEntry, 'chain'>): void {
|
||||
ensureDir(path.dirname(this.auditPath));
|
||||
|
||||
const prevHash = this.getLastAuditHash();
|
||||
|
||||
// Compute current hash
|
||||
const entryWithoutChain = { ...entry };
|
||||
const payload = prevHash + '\n' + JSON.stringify(entryWithoutChain, Object.keys(entryWithoutChain).sort());
|
||||
const currentHash = sha256Hex(payload);
|
||||
|
||||
const record: AuditEntry = {
|
||||
...entry,
|
||||
chain: {
|
||||
prev: prevHash,
|
||||
hash: currentHash
|
||||
}
|
||||
};
|
||||
|
||||
fs.appendFileSync(this.auditPath, JSON.stringify(record) + '\n');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Drift Detection
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
async checkIntegrity(autoRestore: boolean = true, actor: string = 'agent'): Promise<CheckResult> {
|
||||
if (!this.baselines) {
|
||||
throw new Error('Baselines not loaded. Call init() first.');
|
||||
}
|
||||
|
||||
const result: CheckResult = {
|
||||
success: true,
|
||||
timestamp: utcNowIso(),
|
||||
drift_detected: false,
|
||||
files: [],
|
||||
summary: {
|
||||
total: 0,
|
||||
ok: 0,
|
||||
drifted: 0,
|
||||
restored: 0,
|
||||
alerted: 0,
|
||||
errors: 0
|
||||
}
|
||||
};
|
||||
|
||||
for (const [filePath, baseline] of Object.entries(this.baselines.files)) {
|
||||
result.summary.total++;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'error',
|
||||
mode: baseline.mode,
|
||||
error: 'File not found'
|
||||
});
|
||||
result.summary.errors++;
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'error',
|
||||
actor,
|
||||
path: filePath,
|
||||
error: 'File not found'
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
refuseSymlink(filePath);
|
||||
|
||||
const currentSha = sha256File(filePath);
|
||||
|
||||
if (currentSha === baseline.sha256) {
|
||||
// No drift
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'ok',
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha
|
||||
});
|
||||
result.summary.ok++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drift detected
|
||||
result.drift_detected = true;
|
||||
result.summary.drifted++;
|
||||
|
||||
// Generate diff
|
||||
const snapshot = path.join(this.approvedDir, path.basename(filePath));
|
||||
const oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : '';
|
||||
const newText = fs.readFileSync(filePath, 'utf-8');
|
||||
const diff = unifiedDiff(oldText, newText, `approved/${path.basename(filePath)}`, path.basename(filePath));
|
||||
|
||||
const patchPath = path.join(
|
||||
this.patchesDir,
|
||||
`${new Date().toISOString().replace(/[:.]/g, '-')}-drift-${safePatchTag(path.basename(filePath))}.patch`
|
||||
);
|
||||
fs.writeFileSync(patchPath, diff);
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'drift',
|
||||
actor,
|
||||
path: filePath,
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath
|
||||
});
|
||||
|
||||
// Handle based on mode
|
||||
if (baseline.mode === 'restore' && autoRestore) {
|
||||
// Auto-restore
|
||||
try {
|
||||
const quarantinePath = path.join(
|
||||
this.quarantineDir,
|
||||
`${safePatchTag(path.basename(filePath))}.${Date.now()}.quarantine`
|
||||
);
|
||||
fs.copyFileSync(filePath, quarantinePath);
|
||||
|
||||
if (fs.existsSync(snapshot)) {
|
||||
atomicWrite(filePath, fs.readFileSync(snapshot));
|
||||
}
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'restore',
|
||||
actor,
|
||||
path: filePath,
|
||||
mode: baseline.mode,
|
||||
quarantine_path: quarantinePath
|
||||
});
|
||||
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'restored',
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath,
|
||||
quarantine_path: quarantinePath
|
||||
});
|
||||
result.summary.restored++;
|
||||
} catch (error) {
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'error',
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath,
|
||||
error: `Restore failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
result.summary.errors++;
|
||||
}
|
||||
} else {
|
||||
// Alert only
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'drifted',
|
||||
mode: baseline.mode,
|
||||
expected_sha: baseline.sha256,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath
|
||||
});
|
||||
result.summary.alerted++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.files.push({
|
||||
path: filePath,
|
||||
status: 'error',
|
||||
mode: baseline.mode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
result.summary.errors++;
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'error',
|
||||
actor,
|
||||
path: filePath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Approve Changes
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
async approveChange(filePath: string, actor: string, note: string = ''): Promise<void> {
|
||||
if (!this.baselines) {
|
||||
throw new Error('Baselines not loaded');
|
||||
}
|
||||
|
||||
const normalizedFilePath = path.resolve(filePath);
|
||||
|
||||
if (!fs.existsSync(normalizedFilePath)) {
|
||||
throw new Error(`File not found: ${normalizedFilePath}`);
|
||||
}
|
||||
|
||||
refuseSymlink(normalizedFilePath);
|
||||
|
||||
const targets = this.resolveTargets();
|
||||
const target = targets.find(t => t.path === normalizedFilePath);
|
||||
if (!target || target.mode === 'ignore') {
|
||||
throw new Error(`File ${normalizedFilePath} not in policy`);
|
||||
}
|
||||
|
||||
const previousSha = this.baselines.files[normalizedFilePath]?.sha256;
|
||||
const currentSha = sha256File(normalizedFilePath);
|
||||
|
||||
// Generate diff
|
||||
const snapshot = path.join(this.approvedDir, path.basename(normalizedFilePath));
|
||||
const oldText = fs.existsSync(snapshot) ? fs.readFileSync(snapshot, 'utf-8') : '';
|
||||
const newText = fs.readFileSync(normalizedFilePath, 'utf-8');
|
||||
const diff = unifiedDiff(
|
||||
oldText,
|
||||
newText,
|
||||
`approved/${path.basename(normalizedFilePath)}`,
|
||||
path.basename(normalizedFilePath)
|
||||
);
|
||||
|
||||
const patchPath = path.join(
|
||||
this.patchesDir,
|
||||
`${new Date().toISOString().replace(/[:.]/g, '-')}-approve-${safePatchTag(path.basename(normalizedFilePath))}.patch`
|
||||
);
|
||||
fs.writeFileSync(patchPath, diff);
|
||||
|
||||
// Update baseline
|
||||
if (!this.baselines.files[normalizedFilePath]) {
|
||||
this.baselines.files[normalizedFilePath] = {
|
||||
sha256: currentSha,
|
||||
approved_at: utcNowIso(),
|
||||
approved_by: actor,
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
};
|
||||
} else {
|
||||
this.baselines.files[normalizedFilePath].sha256 = currentSha;
|
||||
this.baselines.files[normalizedFilePath].approved_at = utcNowIso();
|
||||
this.baselines.files[normalizedFilePath].approved_by = actor;
|
||||
}
|
||||
|
||||
// Update snapshot
|
||||
fs.copyFileSync(normalizedFilePath, snapshot);
|
||||
|
||||
// Save and audit
|
||||
this.saveBaselines();
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'approve',
|
||||
actor,
|
||||
note,
|
||||
path: normalizedFilePath,
|
||||
expected_sha: previousSha,
|
||||
found_sha: currentSha,
|
||||
patch_path: patchPath
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Status and Verification
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getStatus(filePath?: string): any {
|
||||
if (!this.baselines) {
|
||||
throw new Error('Baselines not loaded');
|
||||
}
|
||||
|
||||
const normalizedFilePath = filePath ? path.resolve(filePath) : null;
|
||||
const files = normalizedFilePath
|
||||
? { [normalizedFilePath]: this.baselines.files[normalizedFilePath] }
|
||||
: this.baselines.files;
|
||||
|
||||
return {
|
||||
baseline_age: this.baselines.created_at,
|
||||
files: Object.entries(files).map(([path, baseline]) => ({
|
||||
path,
|
||||
mode: baseline?.mode,
|
||||
priority: baseline?.priority,
|
||||
has_baseline: !!baseline,
|
||||
baseline_sha: baseline?.sha256,
|
||||
approved_at: baseline?.approved_at,
|
||||
snapshot_exists: fs.existsSync(this.approvedDir + '/' + path.split('/').pop())
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
verifyAuditChain(): { valid: boolean; entries: number; errors: string[] } {
|
||||
if (!fs.existsSync(this.auditPath)) {
|
||||
return { valid: true, entries: 0, errors: [] };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.auditPath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(l => l.trim());
|
||||
|
||||
const errors: string[] = [];
|
||||
let prevHash = CHAIN_GENESIS;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
const entry: AuditEntry = JSON.parse(lines[i]);
|
||||
|
||||
if (entry.chain?.prev !== prevHash) {
|
||||
errors.push(`Line ${i + 1}: Chain break (expected prev=${prevHash}, got=${entry.chain?.prev})`);
|
||||
}
|
||||
|
||||
const entryWithoutChain = { ...entry };
|
||||
delete entryWithoutChain.chain;
|
||||
const payload = prevHash + '\n' + JSON.stringify(entryWithoutChain, Object.keys(entryWithoutChain).sort());
|
||||
const expectedHash = sha256Hex(payload);
|
||||
|
||||
if (entry.chain?.hash !== expectedHash) {
|
||||
errors.push(`Line ${i + 1}: Hash mismatch`);
|
||||
}
|
||||
|
||||
prevHash = entry.chain?.hash || CHAIN_GENESIS;
|
||||
} catch (error) {
|
||||
errors.push(`Line ${i + 1}: Parse error - ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
entries: lines.length,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "NanoClaw file integrity monitoring policy",
|
||||
"nanoclaw_version": "0.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"path": "/workspace/project/data/registered_groups.json",
|
||||
"mode": "restore",
|
||||
"priority": "critical",
|
||||
"description": "Group registration config - prevents unauthorized group access"
|
||||
},
|
||||
{
|
||||
"path": "/workspace/group/CLAUDE.md",
|
||||
"mode": "restore",
|
||||
"priority": "high",
|
||||
"description": "Group-specific agent instructions"
|
||||
},
|
||||
{
|
||||
"path": "/workspace/project/groups/global/CLAUDE.md",
|
||||
"mode": "restore",
|
||||
"priority": "high",
|
||||
"description": "Global agent instructions shared across all groups"
|
||||
},
|
||||
{
|
||||
"pattern": "/workspace/project/container/**/*.ts",
|
||||
"mode": "alert",
|
||||
"priority": "medium",
|
||||
"description": "Container runtime code - alert on changes for awareness"
|
||||
},
|
||||
{
|
||||
"pattern": "/workspace/project/host/**/*.ts",
|
||||
"mode": "alert",
|
||||
"priority": "medium",
|
||||
"description": "Host process code - alert on changes for awareness"
|
||||
},
|
||||
{
|
||||
"pattern": "/workspace/ipc/**/*",
|
||||
"mode": "ignore",
|
||||
"priority": "low",
|
||||
"description": "IPC files change constantly - ignore"
|
||||
},
|
||||
{
|
||||
"pattern": "/workspace/group/conversations/**/*",
|
||||
"mode": "ignore",
|
||||
"priority": "low",
|
||||
"description": "Chat history - expected to change frequently"
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"Mode 'restore': Auto-restore file to approved baseline on drift + alert user",
|
||||
"Mode 'alert': Alert user about drift but do not auto-restore",
|
||||
"Mode 'ignore': No monitoring, file changes are expected",
|
||||
"Patterns use glob syntax with ** for recursive matching"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* ClawSec Advisory Cache Manager for NanoClaw
|
||||
*
|
||||
* Manages fetching, verifying, and caching the ClawSec advisory feed.
|
||||
* Runs on the host side (not in container).
|
||||
*
|
||||
* Security:
|
||||
* - Ed25519 signature verification using Node.js crypto
|
||||
* - Fail-closed policy: invalid signature = reject feed
|
||||
* - TLS 1.2+ enforcement with certificate validation
|
||||
* - Public key embedded (not user-modifiable)
|
||||
* - Cache stored in host-managed directory
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { evaluateAdvisoryRisk } from '../lib/risk.js';
|
||||
|
||||
// ClawSec public key (from clawsec-signing-public.pem)
|
||||
const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json';
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
|
||||
export interface Advisory {
|
||||
id: string;
|
||||
severity: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown' | string;
|
||||
exploitability_rationale?: string;
|
||||
affected: string[];
|
||||
}
|
||||
|
||||
export interface FeedPayload {
|
||||
version: string;
|
||||
updated?: string;
|
||||
advisories: Advisory[];
|
||||
}
|
||||
|
||||
export interface AdvisoryCache {
|
||||
feed: FeedPayload;
|
||||
fetchedAt: string;
|
||||
verified: boolean;
|
||||
publicKeyFingerprint: string;
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
info(msg: string | object, ...args: unknown[]): void;
|
||||
error(msg: string | object, ...args: unknown[]): void;
|
||||
warn(msg: string | object, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export class AdvisoryCacheManager {
|
||||
private cache: AdvisoryCache | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private cacheFile: string;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(dataDir: string, logger: Logger) {
|
||||
this.cacheFile = path.join(dataDir, 'clawsec-advisory-cache.json');
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cache manager. Loads cache from disk and refreshes if stale.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.loadCacheFromDisk();
|
||||
|
||||
if (!this.cache || this.isCacheStale()) {
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Failed to initialize advisory cache');
|
||||
// Continue with stale cache if available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh advisory cache from remote feed.
|
||||
* Thread-safe: prevents concurrent refreshes.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
// Prevent concurrent refreshes
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
this.refreshPromise = this._doRefresh();
|
||||
try {
|
||||
await this.refreshPromise;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache. Returns null if cache is stale or missing.
|
||||
*/
|
||||
getCache(): AdvisoryCache | null {
|
||||
if (!this.cache || this.isCacheStale()) {
|
||||
return null;
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache even if stale (for fallback scenarios)
|
||||
*/
|
||||
getCacheAllowStale(): AdvisoryCache | null {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
private async _doRefresh(): Promise<void> {
|
||||
try {
|
||||
this.logger.info('Refreshing advisory cache from ClawSec feed');
|
||||
|
||||
const feed = await this.fetchAndVerifyFeed();
|
||||
const fingerprint = this.calculateKeyFingerprint();
|
||||
|
||||
this.cache = {
|
||||
feed,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
verified: true,
|
||||
publicKeyFingerprint: fingerprint,
|
||||
};
|
||||
|
||||
await this.saveCacheToDisk();
|
||||
this.logger.info({
|
||||
advisories: feed.advisories.length,
|
||||
updated: feed.updated,
|
||||
}, 'Advisory cache refreshed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Failed to refresh advisory cache');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isCacheStale(): boolean {
|
||||
if (!this.cache) return true;
|
||||
const age = Date.now() - Date.parse(this.cache.fetchedAt);
|
||||
return age > CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
private async fetchAndVerifyFeed(): Promise<FeedPayload> {
|
||||
// Fetch feed and signature in parallel
|
||||
const [payloadRaw, signatureRaw] = await Promise.all([
|
||||
this.secureFetch(FEED_URL),
|
||||
this.secureFetch(`${FEED_URL}.sig`),
|
||||
]);
|
||||
|
||||
// Verify Ed25519 signature
|
||||
if (!this.verifySignature(payloadRaw, signatureRaw)) {
|
||||
throw new Error('Feed signature verification failed (Ed25519)');
|
||||
}
|
||||
|
||||
// Parse and validate
|
||||
const feed = JSON.parse(payloadRaw) as FeedPayload;
|
||||
if (!this.isValidFeed(feed)) {
|
||||
throw new Error('Invalid feed format');
|
||||
}
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
private async secureFetch(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create secure HTTPS agent with TLS 1.2+ enforcement
|
||||
const agent = new https.Agent({
|
||||
minVersion: 'TLSv1.2',
|
||||
rejectUnauthorized: true,
|
||||
ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256',
|
||||
});
|
||||
|
||||
const req = https.get(url, {
|
||||
agent,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
headers: {
|
||||
'User-Agent': 'NanoClaw/1.0',
|
||||
'Accept': 'application/json,text/plain',
|
||||
},
|
||||
}, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', reject);
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Timeout fetching ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private verifySignature(payload: string, signatureBase64: string): boolean {
|
||||
try {
|
||||
// Decode base64 signature
|
||||
const trimmed = signatureBase64.trim();
|
||||
let encoded = trimmed;
|
||||
|
||||
// Handle JSON-wrapped signature: {"signature": "base64..."}
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed.signature === 'string') {
|
||||
encoded = parsed.signature;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, use as-is
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = encoded.replace(/\s+/g, '');
|
||||
const sigBuffer = Buffer.from(normalized, 'base64');
|
||||
|
||||
// Verify Ed25519 signature using Node.js crypto
|
||||
const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM);
|
||||
return crypto.verify(
|
||||
null, // algorithm null = Ed25519 raw mode
|
||||
Buffer.from(payload, 'utf8'),
|
||||
publicKey,
|
||||
sigBuffer
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, 'Signature verification failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidFeed(feed: unknown): feed is FeedPayload {
|
||||
if (typeof feed !== 'object' || !feed) return false;
|
||||
const f = feed as FeedPayload;
|
||||
|
||||
if (typeof f.version !== 'string' || !f.version.trim()) return false;
|
||||
if (!Array.isArray(f.advisories)) return false;
|
||||
|
||||
// Validate each advisory
|
||||
return f.advisories.every((a: unknown) => {
|
||||
if (typeof a !== 'object' || !a) return false;
|
||||
const advisory = a as Advisory;
|
||||
|
||||
return (
|
||||
typeof advisory.id === 'string' &&
|
||||
advisory.id.trim() !== '' &&
|
||||
typeof advisory.severity === 'string' &&
|
||||
advisory.severity.trim() !== '' &&
|
||||
Array.isArray(advisory.affected) &&
|
||||
advisory.affected.every(
|
||||
(affected) => typeof affected === 'string' && affected.trim() !== ''
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private calculateKeyFingerprint(): string {
|
||||
const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM);
|
||||
const der = publicKey.export({ type: 'spki', format: 'der' });
|
||||
return crypto.createHash('sha256').update(der).digest('hex');
|
||||
}
|
||||
|
||||
private async loadCacheFromDisk(): Promise<void> {
|
||||
try {
|
||||
const data = await fs.readFile(this.cacheFile, 'utf8');
|
||||
const parsed = JSON.parse(data) as AdvisoryCache;
|
||||
|
||||
// Validate cache structure
|
||||
if (this.isValidCache(parsed)) {
|
||||
this.cache = parsed;
|
||||
this.logger.info({
|
||||
age: Date.now() - Date.parse(parsed.fetchedAt),
|
||||
advisories: parsed.feed.advisories.length,
|
||||
}, 'Loaded advisory cache from disk');
|
||||
} else {
|
||||
this.logger.warn('Invalid cache format on disk, discarding');
|
||||
this.cache = null;
|
||||
}
|
||||
} catch {
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidCache(cache: unknown): cache is AdvisoryCache {
|
||||
if (typeof cache !== 'object' || !cache) return false;
|
||||
const c = cache as AdvisoryCache;
|
||||
|
||||
return (
|
||||
this.isValidFeed(c.feed) &&
|
||||
typeof c.fetchedAt === 'string' &&
|
||||
typeof c.verified === 'boolean' &&
|
||||
typeof c.publicKeyFingerprint === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
private async saveCacheToDisk(): Promise<void> {
|
||||
if (!this.cache) return;
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(this.cacheFile), { recursive: true });
|
||||
|
||||
// Atomic write: temp file then rename
|
||||
const tempFile = `${this.cacheFile}.tmp`;
|
||||
await fs.writeFile(tempFile, JSON.stringify(this.cache, null, 2), 'utf8');
|
||||
await fs.rename(tempFile, this.cacheFile);
|
||||
|
||||
this.logger.info({ path: this.cacheFile }, 'Advisory cache saved to disk');
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Failed to save advisory cache to disk');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Match advisories against installed skills
|
||||
*/
|
||||
export function findAdvisoryMatches(
|
||||
advisories: Advisory[],
|
||||
skills: Array<{ name: string; version: string | null; dirName: string }>
|
||||
): Array<{
|
||||
advisory: Advisory;
|
||||
skill: { name: string; version: string | null; dirName: string };
|
||||
matchedAffected: string[];
|
||||
}> {
|
||||
const matches: Array<{
|
||||
advisory: Advisory;
|
||||
skill: { name: string; version: string | null; dirName: string };
|
||||
matchedAffected: string[];
|
||||
}> = [];
|
||||
|
||||
for (const advisory of advisories) {
|
||||
for (const skill of skills) {
|
||||
const matchedAffected: string[] = [];
|
||||
|
||||
for (const affected of advisory.affected) {
|
||||
// Parse affected specifier: skill-name or skill-name@version
|
||||
const atIndex = affected.lastIndexOf('@');
|
||||
const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected;
|
||||
const _affectedVersion = atIndex > 0 ? affected.slice(atIndex + 1) : '*';
|
||||
|
||||
// Match by name or directory name
|
||||
if (affectedName === skill.name || affectedName === skill.dirName) {
|
||||
// TODO: implement version range matching
|
||||
matchedAffected.push(affected);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedAffected.length > 0) {
|
||||
matches.push({ advisory, skill, matchedAffected });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Evaluate safety recommendation for a skill
|
||||
*/
|
||||
export function evaluateSkillSafety(advisories: Advisory[]): {
|
||||
safe: boolean;
|
||||
recommendation: 'install' | 'block' | 'review';
|
||||
reason: string;
|
||||
} {
|
||||
return evaluateAdvisoryRisk(advisories);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* ClawSec File Integrity Monitoring IPC Handler for NanoClaw Host
|
||||
*
|
||||
* Add these handlers to /workspace/project/src/ipc.ts
|
||||
*
|
||||
* This processes integrity monitoring requests from agents running in containers.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { IntegrityMonitor } from '../guardian/integrity-monitor';
|
||||
|
||||
// ============================================================================
|
||||
// Integrity Service (Singleton)
|
||||
// ============================================================================
|
||||
|
||||
export class IntegrityService {
|
||||
private monitor: IntegrityMonitor | null = null;
|
||||
private initialized = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
this.monitor = new IntegrityMonitor({
|
||||
policyPath: '/workspace/project/skills/clawsec-nanoclaw/guardian/policy.json',
|
||||
stateDir: '/workspace/project/data/soul-guardian'
|
||||
});
|
||||
|
||||
// Initialize baselines on first run
|
||||
await this.monitor.init('system', 'initial baseline');
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[IntegrityService] Initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[IntegrityService] Initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getMonitor(): IntegrityMonitor {
|
||||
if (!this.monitor) {
|
||||
throw new Error('IntegrityService not initialized');
|
||||
}
|
||||
return this.monitor;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
let integrityServiceInstance: IntegrityService | null = null;
|
||||
|
||||
export function getIntegrityService(): IntegrityService {
|
||||
if (!integrityServiceInstance) {
|
||||
integrityServiceInstance = new IntegrityService();
|
||||
}
|
||||
return integrityServiceInstance;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IPC Handler Integration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add this to the IpcDeps interface in /workspace/project/src/ipc.ts:
|
||||
*
|
||||
* export interface IpcDeps {
|
||||
* // ... existing deps
|
||||
* integrityService?: IntegrityService;
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add these cases to the switch statement in processTaskIpc:
|
||||
*/
|
||||
|
||||
export async function handleIntegrityIpc(
|
||||
task: any,
|
||||
deps: { integrityService?: IntegrityService },
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { type, requestId, groupFolder: _groupFolder } = task;
|
||||
|
||||
if (!deps.integrityService) {
|
||||
logger.warn({ task }, 'IntegrityService not available');
|
||||
if (requestId) {
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: 'IntegrityService not initialized'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const service = deps.integrityService;
|
||||
|
||||
if (!service.isInitialized()) {
|
||||
try {
|
||||
await service.initialize();
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to initialize IntegrityService');
|
||||
if (requestId) {
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'integrity_check':
|
||||
await handleIntegrityCheck(task, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_approve':
|
||||
await handleIntegrityApprove(task, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_status':
|
||||
await handleIntegrityStatus(task, service, logger);
|
||||
break;
|
||||
|
||||
case 'integrity_verify_audit':
|
||||
await handleIntegrityVerifyAudit(task, service, logger);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn({ type }, 'Unknown integrity task type');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Individual Handlers
|
||||
// ============================================================================
|
||||
|
||||
async function handleIntegrityCheck(
|
||||
task: any,
|
||||
service: IntegrityService,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { requestId, mode, autoRestore, groupFolder } = task;
|
||||
|
||||
logger.info({ requestId, groupFolder }, 'Processing integrity_check');
|
||||
|
||||
try {
|
||||
const monitor = service.getMonitor();
|
||||
|
||||
if (mode === 'status') {
|
||||
// Status mode: just return baseline info
|
||||
const status = monitor.getStatus();
|
||||
writeResult(requestId, {
|
||||
success: true,
|
||||
mode: 'status',
|
||||
...status
|
||||
});
|
||||
} else {
|
||||
// Check mode: detect drift and optionally restore
|
||||
const result = await monitor.checkIntegrity(autoRestore !== false, 'agent');
|
||||
|
||||
writeResult(requestId, result);
|
||||
|
||||
if (result.drift_detected) {
|
||||
logger.warn(
|
||||
{ requestId, drifted: result.summary.drifted, restored: result.summary.restored },
|
||||
'Integrity drift detected'
|
||||
);
|
||||
} else {
|
||||
logger.info({ requestId }, 'Integrity check passed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, requestId }, 'Integrity check failed');
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntegrityApprove(
|
||||
task: any,
|
||||
service: IntegrityService,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { requestId, path: filePath, note, approvedBy, groupFolder } = task;
|
||||
|
||||
logger.info({ requestId, filePath, groupFolder }, 'Processing integrity_approve');
|
||||
|
||||
try {
|
||||
const monitor = service.getMonitor();
|
||||
|
||||
await monitor.approveChange(filePath, approvedBy || 'agent', note || '');
|
||||
|
||||
writeResult(requestId, {
|
||||
success: true,
|
||||
path: filePath,
|
||||
approved_at: new Date().toISOString(),
|
||||
approved_by: approvedBy,
|
||||
note
|
||||
});
|
||||
|
||||
logger.info({ requestId, filePath }, 'File change approved');
|
||||
} catch (error) {
|
||||
logger.error({ error, requestId, filePath }, 'Approve change failed');
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
path: filePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntegrityStatus(
|
||||
task: any,
|
||||
service: IntegrityService,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { requestId, path: filePath, groupFolder } = task;
|
||||
|
||||
logger.info({ requestId, filePath, groupFolder }, 'Processing integrity_status');
|
||||
|
||||
try {
|
||||
const monitor = service.getMonitor();
|
||||
const status = monitor.getStatus(filePath);
|
||||
|
||||
writeResult(requestId, {
|
||||
success: true,
|
||||
...status
|
||||
});
|
||||
|
||||
logger.info({ requestId }, 'Status retrieved');
|
||||
} catch (error) {
|
||||
logger.error({ error, requestId }, 'Status check failed');
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntegrityVerifyAudit(
|
||||
task: any,
|
||||
service: IntegrityService,
|
||||
logger: any
|
||||
): Promise<void> {
|
||||
const { requestId, groupFolder } = task;
|
||||
|
||||
logger.info({ requestId, groupFolder }, 'Processing integrity_verify_audit');
|
||||
|
||||
try {
|
||||
const monitor = service.getMonitor();
|
||||
const verification = monitor.verifyAuditChain();
|
||||
|
||||
writeResult(requestId, {
|
||||
success: true,
|
||||
...verification
|
||||
});
|
||||
|
||||
if (!verification.valid) {
|
||||
logger.error({ requestId, errors: verification.errors }, 'Audit chain verification failed');
|
||||
} else {
|
||||
logger.info({ requestId, entries: verification.entries }, 'Audit chain verified');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, requestId }, 'Audit verification failed');
|
||||
writeResult(requestId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function writeResult(requestId: string, result: any): void {
|
||||
const resultDir = '/workspace/ipc/clawsec_results';
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(resultDir)) {
|
||||
fs.mkdirSync(resultDir, { recursive: true });
|
||||
}
|
||||
|
||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration Instructions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* To integrate into NanoClaw host process:
|
||||
*
|
||||
* 1. Add IntegrityService to IpcDeps in src/ipc.ts:
|
||||
*
|
||||
* import { IntegrityService, getIntegrityService } from '../skills/clawsec-nanoclaw/host-services/integrity-handler';
|
||||
*
|
||||
* export interface IpcDeps {
|
||||
* // ... existing deps
|
||||
* integrityService?: IntegrityService;
|
||||
* }
|
||||
*
|
||||
* 2. Initialize in main.ts:
|
||||
*
|
||||
* const integrityService = getIntegrityService();
|
||||
* await integrityService.initialize();
|
||||
*
|
||||
* const ipcDeps: IpcDeps = {
|
||||
* // ... existing deps
|
||||
* integrityService
|
||||
* };
|
||||
*
|
||||
* 3. Add handler calls in processTaskIpc switch statement:
|
||||
*
|
||||
* case 'integrity_check':
|
||||
* case 'integrity_approve':
|
||||
* case 'integrity_status':
|
||||
* case 'integrity_verify_audit':
|
||||
* await handleIntegrityIpc(task, deps, logger);
|
||||
* break;
|
||||
*
|
||||
* 4. Ensure /workspace/ipc/clawsec_results/ directory exists and is writable
|
||||
*
|
||||
* 5. Ensure /workspace/project/data/soul-guardian/ directory exists and is writable
|
||||
*/
|
||||
|
||||
// Example scheduled task for continuous monitoring:
|
||||
//
|
||||
// schedule_task({
|
||||
// prompt: `
|
||||
// Run clawsec_check_integrity to check for file tampering.
|
||||
// If drift_detected is true and files were restored, send alert:
|
||||
// "SECURITY: Unauthorized changes detected and reverted in:
|
||||
// [list restored files with their paths]
|
||||
// Review patches in /workspace/project/data/soul-guardian/patches/"
|
||||
// `,
|
||||
// schedule_type: 'cron',
|
||||
// schedule_value: '*/30 * * * *', // Every 30 minutes
|
||||
// context_mode: 'isolated'
|
||||
// });
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* ClawSec Advisory Feed IPC Handler Additions for NanoClaw
|
||||
*
|
||||
* Add this case to the switch statement in /workspace/project/src/ipc.ts
|
||||
* inside the processTaskIpc function.
|
||||
*
|
||||
* This handler processes advisory cache refresh requests from agents.
|
||||
*/
|
||||
|
||||
import { AdvisoryCacheManager } from './advisory-cache';
|
||||
import { SkillSignatureVerifier } from './skill-signature-handler';
|
||||
|
||||
// Add to IpcDeps interface:
|
||||
export interface IpcDeps {
|
||||
advisoryCacheManager?: AdvisoryCacheManager;
|
||||
signatureVerifier?: SkillSignatureVerifier;
|
||||
}
|
||||
|
||||
interface IpcLogger {
|
||||
info(obj: Record<string, unknown>, msg?: string): void;
|
||||
warn(obj: Record<string, unknown>, msg?: string): void;
|
||||
error(obj: Record<string, unknown>, msg?: string): void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IpcTask = Record<string, any>;
|
||||
|
||||
/**
|
||||
* Placeholder for the host-side writeResponse function.
|
||||
* The actual implementation lives in the NanoClaw host process.
|
||||
*/
|
||||
declare function writeResponse(requestId: string, data: Record<string, unknown>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Handle advisory and signature IPC tasks.
|
||||
*
|
||||
* In the host process, call this from the processTaskIpc switch statement
|
||||
* for the 'refresh_advisory_cache' and 'verify_skill_signature' cases.
|
||||
*/
|
||||
export async function handleAdvisoryIpc(
|
||||
task: IpcTask,
|
||||
deps: IpcDeps,
|
||||
logger: IpcLogger,
|
||||
sourceGroup: string,
|
||||
): Promise<void> {
|
||||
switch (task.type) {
|
||||
case 'refresh_advisory_cache':
|
||||
// Any group can request cache refresh (rate-limited by cache manager)
|
||||
logger.info({ sourceGroup }, 'Advisory cache refresh requested via IPC');
|
||||
if (deps.advisoryCacheManager) {
|
||||
try {
|
||||
await deps.advisoryCacheManager.refresh();
|
||||
logger.info({ sourceGroup }, 'Advisory cache refreshed successfully');
|
||||
} catch (error) {
|
||||
logger.error({ error, sourceGroup }, 'Advisory cache refresh failed');
|
||||
}
|
||||
} else {
|
||||
logger.warn({ sourceGroup }, 'Advisory cache manager not initialized');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'verify_skill_signature': {
|
||||
// Skill signature verification (Phase 1)
|
||||
const { requestId, packagePath, signaturePath } = task;
|
||||
|
||||
logger.info({ sourceGroup, requestId, packagePath }, 'Verifying skill signature');
|
||||
|
||||
try {
|
||||
if (!deps.signatureVerifier) {
|
||||
throw new Error('Signature verification service not available');
|
||||
}
|
||||
|
||||
const result = await deps.signatureVerifier.verify({
|
||||
packagePath,
|
||||
signaturePath,
|
||||
});
|
||||
|
||||
await writeResponse(requestId, {
|
||||
success: true,
|
||||
message: result.valid ? 'Signature valid' : 'Signature invalid',
|
||||
data: result,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ sourceGroup, requestId, valid: result.valid, signer: result.signer },
|
||||
'Signature verification completed'
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error & { code?: string };
|
||||
logger.error({ error, sourceGroup, requestId, packagePath }, 'Signature verification failed');
|
||||
|
||||
const errorCode = err.code || 'CRYPTO_ERROR';
|
||||
await writeResponse(requestId, {
|
||||
success: false,
|
||||
message: err.message || 'Verification failed',
|
||||
error: {
|
||||
code: errorCode,
|
||||
details: error
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Skill Signature Verification Handler for NanoClaw
|
||||
*
|
||||
* Verifies Ed25519 signatures on skill packages to prevent supply chain attacks.
|
||||
* Uses the same pinned public key as advisory feed verification.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
verifyDetachedSignatureWithDetails,
|
||||
loadPublicKey,
|
||||
sha256File,
|
||||
SecurityPolicyError
|
||||
} from '../lib/signatures.js';
|
||||
|
||||
/**
|
||||
* Default location of ClawSec's pinned public key (same as advisory feed)
|
||||
*/
|
||||
const DEFAULT_PUBLIC_KEY_PATH = path.join(
|
||||
__dirname,
|
||||
'../advisories/feed-signing-public.pem'
|
||||
);
|
||||
|
||||
/**
|
||||
* Verification result interface
|
||||
*/
|
||||
export interface VerificationResult {
|
||||
valid: boolean;
|
||||
signer: string | null;
|
||||
packageHash: string;
|
||||
verifiedAt: string;
|
||||
algorithm: 'Ed25519';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification parameters interface
|
||||
*/
|
||||
export interface VerifyParams {
|
||||
packagePath: string;
|
||||
signaturePath: string;
|
||||
}
|
||||
|
||||
const ALLOWED_PACKAGE_ROOTS = [
|
||||
'/tmp',
|
||||
'/var/tmp',
|
||||
'/workspace/ipc',
|
||||
'/workspace/project/data',
|
||||
'/workspace/project/tmp',
|
||||
'/workspace/project/downloads',
|
||||
] as const;
|
||||
|
||||
const ALLOWED_PACKAGE_EXTENSIONS = ['.zip', '.tar', '.tgz', '.tar.gz'] as const;
|
||||
|
||||
function isWithinAllowedRoots(filePath: string): boolean {
|
||||
return ALLOWED_PACKAGE_ROOTS.some((root) => filePath === root || filePath.startsWith(`${root}/`));
|
||||
}
|
||||
|
||||
function hasAllowedPackageExtension(filePath: string): boolean {
|
||||
return ALLOWED_PACKAGE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
||||
}
|
||||
|
||||
function normalizeAndValidatePath(rawPath: string, kind: 'package' | 'signature'): string {
|
||||
if (!path.isAbsolute(rawPath)) {
|
||||
throw new SecurityPolicyError(`${kind} path must be absolute`);
|
||||
}
|
||||
|
||||
const resolved = path.resolve(rawPath);
|
||||
if (!isWithinAllowedRoots(resolved)) {
|
||||
throw new SecurityPolicyError(
|
||||
`${kind} path must be under allowed roots: ${ALLOWED_PACKAGE_ROOTS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'package' && !hasAllowedPackageExtension(resolved)) {
|
||||
throw new SecurityPolicyError(
|
||||
`package path must use one of: ${ALLOWED_PACKAGE_EXTENSIONS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'signature' && !resolved.endsWith('.sig')) {
|
||||
throw new SecurityPolicyError('signature path must end with .sig');
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function ensureExistingRegularFile(filePath: string, kind: 'package' | 'signature'): string {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new SecurityPolicyError(`${kind} file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new SecurityPolicyError(`${kind} path cannot be a symlink`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new SecurityPolicyError(`${kind} path must be a regular file`);
|
||||
}
|
||||
|
||||
const realPath = fs.realpathSync(filePath);
|
||||
if (!isWithinAllowedRoots(realPath)) {
|
||||
throw new SecurityPolicyError(`${kind} real path escapes allowed roots`);
|
||||
}
|
||||
|
||||
return realPath;
|
||||
}
|
||||
|
||||
function validatePackagePath(rawPackagePath: string): string {
|
||||
const resolved = normalizeAndValidatePath(rawPackagePath, 'package');
|
||||
return ensureExistingRegularFile(resolved, 'package');
|
||||
}
|
||||
|
||||
function validateSignaturePath(rawSignaturePath: string): string {
|
||||
const resolved = normalizeAndValidatePath(rawSignaturePath, 'signature');
|
||||
return ensureExistingRegularFile(resolved, 'signature');
|
||||
}
|
||||
|
||||
/**
|
||||
* Service class for skill package signature verification
|
||||
*/
|
||||
export class SkillSignatureVerifier {
|
||||
private publicKeyPath: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private logger: any;
|
||||
|
||||
constructor(
|
||||
publicKeyPath: string = DEFAULT_PUBLIC_KEY_PATH,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logger?: any
|
||||
) {
|
||||
this.publicKeyPath = publicKeyPath;
|
||||
this.logger = logger || console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Ed25519 signature of a skill package
|
||||
*/
|
||||
async verify(params: VerifyParams): Promise<VerificationResult> {
|
||||
const {
|
||||
packagePath,
|
||||
signaturePath,
|
||||
} = params;
|
||||
|
||||
let validatedPackagePath: string;
|
||||
let validatedSignaturePath: string;
|
||||
try {
|
||||
validatedPackagePath = validatePackagePath(packagePath);
|
||||
validatedSignaturePath = validateSignaturePath(signaturePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
// Load pinned ClawSec key only
|
||||
let keyPem: string;
|
||||
try {
|
||||
if (!fs.existsSync(this.publicKeyPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: `Public key file not found: ${this.publicKeyPath}`
|
||||
};
|
||||
}
|
||||
|
||||
keyPem = fs.readFileSync(this.publicKeyPath, 'utf8');
|
||||
loadPublicKey(keyPem); // Validate pinned key
|
||||
} catch (error) {
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: `Failed to load public key: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
|
||||
// Compute package hash (always, for integrity tracking)
|
||||
let packageHash: string;
|
||||
try {
|
||||
packageHash = sha256File(validatedPackagePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: `Failed to compute package hash: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const verificationResult = verifyDetachedSignatureWithDetails(
|
||||
validatedPackagePath,
|
||||
validatedSignaturePath,
|
||||
keyPem
|
||||
);
|
||||
|
||||
// Return structured result
|
||||
return {
|
||||
valid: verificationResult.valid,
|
||||
signer: verificationResult.valid ? 'clawsec' : null,
|
||||
packageHash,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: verificationResult.error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public key fingerprint for auditing
|
||||
*/
|
||||
getPublicKeyFingerprint(): string {
|
||||
try {
|
||||
const keyPem = fs.readFileSync(this.publicKeyPath, 'utf8');
|
||||
const keyObject = loadPublicKey(keyPem);
|
||||
const _keyDer = keyObject.export({ type: 'spki', format: 'der' });
|
||||
return `sha256:${sha256File(this.publicKeyPath).substring(0, 16)}`;
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Failed to compute public key fingerprint');
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error codes for IPC responses
|
||||
*/
|
||||
export const ErrorCodes = {
|
||||
SIGNATURE_INVALID: 'SIGNATURE_INVALID',
|
||||
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
|
||||
CRYPTO_ERROR: 'CRYPTO_ERROR',
|
||||
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Map verification errors to standard error codes
|
||||
*/
|
||||
export function mapErrorCode(error: string): string {
|
||||
if (error.includes('not found')) {
|
||||
return ErrorCodes.FILE_NOT_FOUND;
|
||||
}
|
||||
if (error.includes('Invalid signature') || error.includes('verification failed')) {
|
||||
return ErrorCodes.SIGNATURE_INVALID;
|
||||
}
|
||||
if (error.includes('public key') || error.includes('PEM')) {
|
||||
return ErrorCodes.CRYPTO_ERROR;
|
||||
}
|
||||
return ErrorCodes.CRYPTO_ERROR;
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Advisory Feed Loading and Matching for NanoClaw
|
||||
* Ported from ClawSec's feed.mjs with fail-closed verification
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
Advisory,
|
||||
AdvisoryFeed,
|
||||
AdvisoryMatch,
|
||||
AffectedSpecifier,
|
||||
SignatureVerificationOptions,
|
||||
} from './types.js';
|
||||
import {
|
||||
verifySignedPayload,
|
||||
parseChecksumsManifest,
|
||||
verifyChecksums,
|
||||
fetchText,
|
||||
defaultChecksumsUrl,
|
||||
SecurityPolicyError,
|
||||
} from './signatures.js';
|
||||
|
||||
const DEFAULT_FEED_URL = 'https://clawsec.prompt.security/advisories/feed.json';
|
||||
|
||||
/**
|
||||
* Validates that a payload is a valid advisory feed.
|
||||
*/
|
||||
export function isValidFeedPayload(raw: unknown): raw is AdvisoryFeed {
|
||||
if (typeof raw !== 'object' || raw === null) return false;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.version !== 'string' || !obj.version.trim()) return false;
|
||||
if (!Array.isArray(obj.advisories)) return false;
|
||||
|
||||
for (const advisory of obj.advisories) {
|
||||
if (typeof advisory !== 'object' || advisory === null) return false;
|
||||
const adv = advisory as Record<string, unknown>;
|
||||
|
||||
if (typeof adv.id !== 'string' || !adv.id.trim()) return false;
|
||||
if (typeof adv.severity !== 'string' || !adv.severity.trim()) return false;
|
||||
if (!Array.isArray(adv.affected)) return false;
|
||||
if (!adv.affected.every((entry) => typeof entry === 'string' && entry.trim())) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an affected specifier like "skill-name@version-spec".
|
||||
*/
|
||||
export function parseAffectedSpecifier(rawSpecifier: string): AffectedSpecifier | null {
|
||||
const specifier = rawSpecifier.trim();
|
||||
if (!specifier) return null;
|
||||
|
||||
const atIndex = specifier.lastIndexOf('@');
|
||||
if (atIndex <= 0) {
|
||||
return { name: specifier, versionSpec: '*' };
|
||||
}
|
||||
|
||||
return {
|
||||
name: specifier.slice(0, atIndex),
|
||||
versionSpec: specifier.slice(atIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a skill name for comparison.
|
||||
*/
|
||||
export function normalizeSkillName(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/[^a-z0-9-]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version matches a version specifier.
|
||||
* Supports: exact match, semver range (^, ~, *), wildcards
|
||||
*/
|
||||
export function versionMatches(version: string, versionSpec: string): boolean {
|
||||
const v = version.trim();
|
||||
const spec = versionSpec.trim();
|
||||
|
||||
// Wildcard matches everything
|
||||
if (spec === '*' || spec === '') return true;
|
||||
|
||||
// Exact match
|
||||
if (v === spec) return true;
|
||||
|
||||
// Parse semver components
|
||||
const parseVersion = (ver: string): number[] => {
|
||||
const match = ver.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) return [];
|
||||
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
||||
};
|
||||
|
||||
const vParts = parseVersion(v);
|
||||
const specParts = parseVersion(spec.replace(/^[~^]/, ''));
|
||||
|
||||
if (vParts.length === 0 || specParts.length === 0) return false;
|
||||
|
||||
// Caret range (^1.2.3): compatible with 1.x.x where x >= 2.3
|
||||
if (spec.startsWith('^')) {
|
||||
if (vParts[0] !== specParts[0]) return false;
|
||||
if (vParts[0] === 0) {
|
||||
// ^0.2.3 means 0.2.x where x >= 3
|
||||
if (vParts[1] !== specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
}
|
||||
// ^1.2.3 means 1.x.x where x.x >= 2.3
|
||||
if (vParts[1] > specParts[1]) return true;
|
||||
if (vParts[1] < specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
}
|
||||
|
||||
// Tilde range (~1.2.3): patch-level compatibility (1.2.x where x >= 3)
|
||||
if (spec.startsWith('~')) {
|
||||
if (vParts[0] !== specParts[0]) return false;
|
||||
if (vParts[1] !== specParts[1]) return false;
|
||||
return vParts[2] >= specParts[2];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an affected specifier matches a skill name/version.
|
||||
* Optionally matches against a skill directory name as alias.
|
||||
*/
|
||||
export function matchesAffectedSpecifier(
|
||||
affected: string,
|
||||
skillName: string,
|
||||
skillVersion: string | null,
|
||||
skillDirName?: string
|
||||
): boolean {
|
||||
const parsed = parseAffectedSpecifier(affected);
|
||||
if (!parsed) return false;
|
||||
|
||||
const normalizedTarget = normalizeSkillName(parsed.name);
|
||||
const normalizedSkillName = normalizeSkillName(skillName);
|
||||
const normalizedDirName = skillDirName ? normalizeSkillName(skillDirName) : null;
|
||||
|
||||
if (normalizedTarget !== normalizedSkillName && normalizedTarget !== normalizedDirName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!skillVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return versionMatches(skillVersion, parsed.versionSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads advisory feed from a remote URL with signature verification.
|
||||
*/
|
||||
export async function loadRemoteFeed(
|
||||
feedUrl: string,
|
||||
options: SignatureVerificationOptions
|
||||
): Promise<AdvisoryFeed | null> {
|
||||
const signatureUrl = options.signatureUrl || `${feedUrl}.sig`;
|
||||
const checksumsUrl = options.checksumsUrl || defaultChecksumsUrl(feedUrl);
|
||||
const checksumsSignatureUrl = options.checksumsSignatureUrl || `${checksumsUrl}.sig`;
|
||||
const publicKeyPem = options.publicKeyPem;
|
||||
const checksumsPublicKeyPem = options.checksumsPublicKeyPem || publicKeyPem;
|
||||
const allowUnsigned = options.allowUnsigned || false;
|
||||
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||
|
||||
try {
|
||||
const payloadRaw = await fetchText(feedUrl);
|
||||
if (!payloadRaw) return null;
|
||||
|
||||
if (!allowUnsigned) {
|
||||
const signatureRaw = await fetchText(signatureUrl);
|
||||
if (!signatureRaw) return null;
|
||||
|
||||
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify checksum manifest if available
|
||||
if (verifyChecksumManifest) {
|
||||
const checksumsRaw = await fetchText(checksumsUrl);
|
||||
const checksumsSignatureRaw = await fetchText(checksumsSignatureUrl);
|
||||
|
||||
// Only proceed if BOTH checksum files are present
|
||||
if (checksumsRaw && checksumsSignatureRaw) {
|
||||
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
||||
return null; // Fail-closed: invalid signature
|
||||
}
|
||||
|
||||
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
||||
const checksumFeedEntry = feedUrl.split('/').pop() || 'feed.json';
|
||||
const checksumSignatureEntry = signatureUrl.split('/').pop() || 'feed.json.sig';
|
||||
verifyChecksums(checksumsManifest, {
|
||||
[checksumFeedEntry]: payloadRaw,
|
||||
[checksumSignatureEntry]: signatureRaw,
|
||||
});
|
||||
}
|
||||
// If checksum files missing: continue without checksum verification
|
||||
// (feed signature was already verified above)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
if (!isValidFeedPayload(payload)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Security policy violations return null to allow graceful fallback to local feed
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
return null;
|
||||
}
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads advisory feed from a local file with signature verification.
|
||||
*/
|
||||
export async function loadLocalFeed(
|
||||
feedPath: string,
|
||||
options: SignatureVerificationOptions
|
||||
): Promise<AdvisoryFeed> {
|
||||
const signaturePath = options.signatureUrl || `${feedPath}.sig`;
|
||||
const checksumsPath = options.checksumsUrl || path.join(path.dirname(feedPath), 'checksums.json');
|
||||
const checksumsSignaturePath = options.checksumsSignatureUrl || `${checksumsPath}.sig`;
|
||||
const publicKeyPem = options.publicKeyPem;
|
||||
const checksumsPublicKeyPem = options.checksumsPublicKeyPem || publicKeyPem;
|
||||
const allowUnsigned = options.allowUnsigned || false;
|
||||
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||
|
||||
const payloadRaw = await fs.readFile(feedPath, 'utf8');
|
||||
|
||||
if (!allowUnsigned) {
|
||||
const signatureRaw = await fs.readFile(signaturePath, 'utf8');
|
||||
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||
throw new Error(`Feed signature verification failed for local feed: ${feedPath}`);
|
||||
}
|
||||
|
||||
if (verifyChecksumManifest) {
|
||||
const checksumsRaw = await fs.readFile(checksumsPath, 'utf8');
|
||||
const checksumsSignatureRaw = await fs.readFile(checksumsSignaturePath, 'utf8');
|
||||
|
||||
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
||||
throw new Error(`Checksum manifest signature verification failed: ${checksumsPath}`);
|
||||
}
|
||||
|
||||
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
||||
const checksumFeedEntry = path.basename(feedPath);
|
||||
const checksumSignatureEntry = path.basename(signaturePath);
|
||||
verifyChecksums(checksumsManifest, {
|
||||
[checksumFeedEntry]: payloadRaw,
|
||||
[checksumSignatureEntry]: signatureRaw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
if (!isValidFeedPayload(payload)) {
|
||||
throw new Error(`Invalid advisory feed format: ${feedPath}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads advisory feed from remote or falls back to local.
|
||||
*/
|
||||
export async function loadFeed(
|
||||
feedUrl: string = DEFAULT_FEED_URL,
|
||||
localFeedPath: string,
|
||||
publicKeyPem: string,
|
||||
allowUnsigned: boolean = false
|
||||
): Promise<{ feed: AdvisoryFeed; source: string }> {
|
||||
const options: SignatureVerificationOptions = {
|
||||
publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest: true,
|
||||
};
|
||||
|
||||
// Try remote feed first
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl, options);
|
||||
if (remoteFeed) {
|
||||
return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
||||
}
|
||||
|
||||
// Fall back to local feed
|
||||
const localFeed = await loadLocalFeed(localFeedPath, options);
|
||||
return { feed: localFeed, source: `local:${localFeedPath}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an advisory looks high-risk.
|
||||
*/
|
||||
export function advisoryLooksHighRisk(advisory: Advisory): boolean {
|
||||
const type = advisory.type.toLowerCase();
|
||||
const severity = advisory.severity.toLowerCase();
|
||||
const exploitability = (advisory.exploitability_score || 'unknown').toLowerCase();
|
||||
const combined = `${advisory.title} ${advisory.description} ${advisory.action}`.toLowerCase();
|
||||
|
||||
if (type.includes('malicious')) return true;
|
||||
if (severity === 'critical') return true;
|
||||
if (exploitability === 'high') return true;
|
||||
if (/\b(malicious|exfiltrate|exfiltration|backdoor|trojan|stealer|credential theft)\b/.test(combined)) return true;
|
||||
if (/\b(remove|uninstall|disable|do not use|quarantine)\b/.test(combined)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds advisory matches for a skill.
|
||||
*/
|
||||
export function findAdvisoryMatches(
|
||||
feed: AdvisoryFeed,
|
||||
skillName: string,
|
||||
version: string | null
|
||||
): AdvisoryMatch[] {
|
||||
const matches: AdvisoryMatch[] = [];
|
||||
|
||||
for (const advisory of feed.advisories) {
|
||||
const affected = advisory.affected || [];
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
for (const specifier of affected) {
|
||||
if (!matchesAffectedSpecifier(specifier, skillName, version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match found
|
||||
matches.push({
|
||||
advisory,
|
||||
matchedSpecifier: specifier,
|
||||
isHighRisk: advisoryLooksHighRisk(advisory),
|
||||
});
|
||||
break; // Only count each advisory once
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate strings from an array.
|
||||
*/
|
||||
export function uniqueStrings(arr: string[]): string[] {
|
||||
return Array.from(new Set(arr));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from 'fs';
|
||||
|
||||
export function fileExists(filePath: string): boolean {
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
export function loadBinaryFile(filePath: string): Buffer {
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
export function loadUtf8File(filePath: string): string {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shared advisory risk evaluation for NanoClaw host + MCP layers.
|
||||
*/
|
||||
|
||||
export type SkillSafetyRecommendation = 'install' | 'block' | 'review';
|
||||
|
||||
export interface AdvisoryRiskInput {
|
||||
severity?: string;
|
||||
type?: string;
|
||||
action?: string;
|
||||
exploitability_score?: string;
|
||||
}
|
||||
|
||||
export interface AdvisoryRiskEvaluation {
|
||||
safe: boolean;
|
||||
recommendation: SkillSafetyRecommendation;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function normalizeExploitabilityScore(score: unknown): 'high' | 'medium' | 'low' | 'unknown' {
|
||||
const value = String(score || '').toLowerCase().trim();
|
||||
if (value === 'high' || value === 'medium' || value === 'low') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function evaluateAdvisoryRisk(advisories: AdvisoryRiskInput[]): AdvisoryRiskEvaluation {
|
||||
if (advisories.length === 0) {
|
||||
return { safe: true, recommendation: 'install', reason: 'No advisories found' };
|
||||
}
|
||||
|
||||
const hasMalicious = advisories.some((a) => String(a.type || '').toLowerCase().includes('malicious'));
|
||||
const hasRemoveAction = advisories.some((a) =>
|
||||
/\b(remove|uninstall|disable|quarantine|block)\b/i.test(String(a.action || ''))
|
||||
);
|
||||
const hasCritical = advisories.some((a) => String(a.severity || '').toLowerCase() === 'critical');
|
||||
const hasHigh = advisories.some((a) => String(a.severity || '').toLowerCase() === 'high');
|
||||
const hasHighExploitability = advisories.some(
|
||||
(a) => normalizeExploitabilityScore(a.exploitability_score) === 'high'
|
||||
);
|
||||
|
||||
if (hasMalicious || hasRemoveAction) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Malicious skill or removal recommended by ClawSec',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCritical && hasHighExploitability) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Critical advisory with high exploitability context - do not install',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCritical) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Critical security advisory - do not install',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHighExploitability) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'High exploitability advisory - urgent user review strongly recommended',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHigh) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'High severity advisory - user review strongly recommended',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'Advisory found - review details before installing',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Ed25519 Signature Verification for NanoClaw
|
||||
* Ported from ClawSec's feed.mjs
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import { ChecksumsManifest } from './types.js';
|
||||
import { fileExists, loadBinaryFile, loadUtf8File } from './local_file_io.js';
|
||||
|
||||
/**
|
||||
* Allowed domains for feed/signature fetching.
|
||||
* Only connections to these domains are permitted for security.
|
||||
*/
|
||||
const ALLOWED_DOMAINS = [
|
||||
'clawsec.prompt.security',
|
||||
'prompt.security',
|
||||
'raw.githubusercontent.com',
|
||||
'github.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom error class for security policy violations.
|
||||
* These errors should always propagate and never be silently caught.
|
||||
*/
|
||||
export class SecurityPolicyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecurityPolicyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
|
||||
*/
|
||||
function createSecureAgent(): https.Agent {
|
||||
return new https.Agent({
|
||||
// Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1)
|
||||
minVersion: 'TLSv1.2',
|
||||
// Ensure certificate validation is enabled (reject unauthorized certificates)
|
||||
rejectUnauthorized: true,
|
||||
// Use strong cipher suites
|
||||
ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL is from an allowed domain.
|
||||
*/
|
||||
function isAllowedDomain(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow HTTPS protocol
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Check if hostname matches any allowed domain
|
||||
return ALLOWED_DOMAINS.some(
|
||||
(allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure wrapper around fetch with TLS enforcement and domain validation.
|
||||
*/
|
||||
export async function secureFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
// Validate domain before making request
|
||||
if (!isAllowedDomain(url)) {
|
||||
throw new SecurityPolicyError(
|
||||
`Security policy violation: URL domain not allowed. ` +
|
||||
`Only connections to ${ALLOWED_DOMAINS.join(', ')} are permitted. ` +
|
||||
`Blocked: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
// Use secure HTTPS agent with TLS 1.2+ enforcement
|
||||
const agent = createSecureAgent();
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
// @ts-expect-error - agent is supported in Node.js fetch
|
||||
agent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a signature from various formats (base64 string or JSON).
|
||||
*/
|
||||
function decodeSignature(signatureRaw: string): Buffer | null {
|
||||
const trimmed = signatureRaw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let encoded = trimmed;
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && typeof parsed.signature === 'string') {
|
||||
encoded = parsed.signature;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = encoded.replace(/\s+/g, '');
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
return Buffer.from(normalized, 'base64');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an Ed25519 signature for a payload.
|
||||
*/
|
||||
export function verifySignedPayload(
|
||||
payloadRaw: string,
|
||||
signatureRaw: string,
|
||||
publicKeyPem: string
|
||||
): boolean {
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
if (!signature) return false;
|
||||
|
||||
const keyPem = publicKeyPem.trim();
|
||||
if (!keyPem) return false;
|
||||
|
||||
try {
|
||||
const publicKey = crypto.createPublicKey(keyPem);
|
||||
return crypto.verify(null, Buffer.from(payloadRaw, 'utf8'), publicKey, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of content.
|
||||
*/
|
||||
export function sha256Hex(content: string | Buffer): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of a file.
|
||||
* Convenience wrapper for file-based integrity monitoring and package verification.
|
||||
*/
|
||||
export function sha256File(filePath: string): string {
|
||||
const data = loadBinaryFile(filePath);
|
||||
return sha256Hex(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates an Ed25519 public key from PEM format.
|
||||
* @throws {SecurityPolicyError} if PEM format is invalid
|
||||
*/
|
||||
export function loadPublicKey(pemString: string): crypto.KeyObject {
|
||||
const trimmed = pemString.trim();
|
||||
if (!trimmed.startsWith('-----BEGIN PUBLIC KEY-----')) {
|
||||
throw new SecurityPolicyError('Invalid PEM format: must start with -----BEGIN PUBLIC KEY-----');
|
||||
}
|
||||
|
||||
try {
|
||||
return crypto.createPublicKey(trimmed);
|
||||
} catch (error) {
|
||||
throw new SecurityPolicyError(
|
||||
`Failed to load public key: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies Ed25519 detached signature for a file.
|
||||
* Matches the API of verify_detached_ed25519.mjs from OpenClaw.
|
||||
*
|
||||
* @param dataPath - Path to the file to verify
|
||||
* @param signaturePath - Path to the detached signature file (.sig)
|
||||
* @param publicKeyPem - Ed25519 public key in PEM format
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export function verifyDetachedSignature(
|
||||
dataPath: string,
|
||||
signaturePath: string,
|
||||
publicKeyPem: string
|
||||
): boolean {
|
||||
try {
|
||||
const data = loadBinaryFile(dataPath);
|
||||
const signatureRaw = loadUtf8File(signaturePath);
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
|
||||
if (!signature) return false;
|
||||
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem.trim());
|
||||
return crypto.verify(null, data, publicKey, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies detached signature with detailed error information.
|
||||
* Useful for debugging signature verification failures.
|
||||
*
|
||||
* @param dataPath - Path to the file to verify
|
||||
* @param signaturePath - Path to the detached signature file (.sig)
|
||||
* @param publicKeyPem - Ed25519 public key in PEM format
|
||||
* @returns Object with valid flag and optional error message
|
||||
*/
|
||||
export function verifyDetachedSignatureWithDetails(
|
||||
dataPath: string,
|
||||
signaturePath: string,
|
||||
publicKeyPem: string
|
||||
): { valid: boolean; error?: string } {
|
||||
try {
|
||||
if (!fileExists(dataPath)) {
|
||||
return { valid: false, error: 'Data file not found' };
|
||||
}
|
||||
if (!fileExists(signaturePath)) {
|
||||
return { valid: false, error: 'Signature file not found' };
|
||||
}
|
||||
|
||||
const data = loadBinaryFile(dataPath);
|
||||
const signatureRaw = loadUtf8File(signaturePath);
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
|
||||
if (!signature) {
|
||||
return { valid: false, error: 'Invalid signature format' };
|
||||
}
|
||||
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem.trim());
|
||||
const valid = crypto.verify(null, data, publicKey, signature);
|
||||
|
||||
return { valid, error: valid ? undefined : 'Signature verification failed' };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Verification error: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies multiple files against expected hashes.
|
||||
* Returns list of files that don't match their expected hashes.
|
||||
*
|
||||
* @param files - Map of file paths to expected SHA-256 hashes
|
||||
* @returns Array of mismatches with path, expected, and actual hashes
|
||||
*/
|
||||
export function verifyFileHashes(
|
||||
files: Record<string, string>
|
||||
): { path: string; expected: string; actual: string }[] {
|
||||
const mismatches = [];
|
||||
|
||||
for (const [path, expectedHash] of Object.entries(files)) {
|
||||
try {
|
||||
const actualHash = sha256File(path);
|
||||
if (actualHash !== expectedHash) {
|
||||
mismatches.push({ path, expected: expectedHash, actual: actualHash });
|
||||
}
|
||||
} catch (error) {
|
||||
// File missing or unreadable
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: expectedHash,
|
||||
actual: `ERROR: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts SHA-256 value from various formats.
|
||||
*/
|
||||
function extractSha256Value(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && 'sha256' in value) {
|
||||
const sha256 = (value as { sha256: unknown }).sha256;
|
||||
if (typeof sha256 === 'string') {
|
||||
const normalized = sha256.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a checksums manifest JSON.
|
||||
*/
|
||||
export function parseChecksumsManifest(manifestRaw: string): ChecksumsManifest {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(manifestRaw);
|
||||
} catch {
|
||||
throw new Error('Checksum manifest is not valid JSON');
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('Checksum manifest must be an object');
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
const algorithmRaw = typeof obj.algorithm === 'string' ? obj.algorithm.trim().toLowerCase() : 'sha256';
|
||||
if (algorithmRaw !== 'sha256') {
|
||||
throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || '(empty)'}`);
|
||||
}
|
||||
|
||||
// Support legacy manifest formats
|
||||
const schemaVersion = (
|
||||
typeof obj.schema_version === 'string' ? obj.schema_version.trim() :
|
||||
typeof obj.version === 'string' ? obj.version.trim() :
|
||||
typeof obj.generated_at === 'string' ? obj.generated_at.trim() :
|
||||
'1'
|
||||
);
|
||||
|
||||
if (!schemaVersion) {
|
||||
throw new Error('Checksum manifest missing schema_version');
|
||||
}
|
||||
|
||||
if (typeof obj.files !== 'object' || obj.files === null) {
|
||||
throw new Error('Checksum manifest missing files object');
|
||||
}
|
||||
|
||||
const files: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj.files)) {
|
||||
if (!key.trim()) continue;
|
||||
const digest = extractSha256Value(value);
|
||||
if (!digest) {
|
||||
throw new Error(`Invalid checksum digest entry for ${key}`);
|
||||
}
|
||||
files[key] = digest;
|
||||
}
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
throw new Error('Checksum manifest has no usable file digests');
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: schemaVersion,
|
||||
algorithm: 'sha256',
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a checksum entry name for matching.
|
||||
*/
|
||||
function normalizeChecksumEntryName(entryName: string): string {
|
||||
return entryName
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^(?:\.\/)+/, '')
|
||||
.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a checksum manifest entry by name.
|
||||
*/
|
||||
function resolveChecksumManifestEntry(
|
||||
files: Record<string, string>,
|
||||
entryName: string
|
||||
): { key: string; digest: string } | null {
|
||||
const normalizedEntry = normalizeChecksumEntryName(entryName);
|
||||
if (!normalizedEntry) return null;
|
||||
|
||||
// Try direct match and common variations
|
||||
const directCandidates = [
|
||||
normalizedEntry,
|
||||
normalizedEntry.split('/').pop() || '',
|
||||
`advisories/${normalizedEntry.split('/').pop() || ''}`,
|
||||
].filter((c, i, a) => c && a.indexOf(c) === i);
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (candidate in files) {
|
||||
return { key: candidate, digest: files[candidate] };
|
||||
}
|
||||
}
|
||||
|
||||
// Try basename matching
|
||||
const basename = normalizedEntry.split('/').pop() || '';
|
||||
if (!basename) return null;
|
||||
|
||||
const basenameMatches = Object.entries(files).filter(([key]) => {
|
||||
const normalizedKey = normalizeChecksumEntryName(key);
|
||||
return normalizedKey.split('/').pop() === basename;
|
||||
});
|
||||
|
||||
if (basenameMatches.length > 1) {
|
||||
throw new Error(
|
||||
`Checksum manifest entry is ambiguous for ${entryName}; ` +
|
||||
`multiple manifest keys share basename ${basename}`
|
||||
);
|
||||
}
|
||||
|
||||
if (basenameMatches.length === 1) {
|
||||
const [resolvedKey, digest] = basenameMatches[0];
|
||||
return { key: resolvedKey, digest };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies checksums for expected entries.
|
||||
*/
|
||||
export function verifyChecksums(
|
||||
manifest: ChecksumsManifest,
|
||||
expectedEntries: Record<string, string | Buffer>
|
||||
): void {
|
||||
for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
|
||||
if (!entryName) continue;
|
||||
|
||||
const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
|
||||
if (!resolved) {
|
||||
throw new Error(`Checksum manifest missing required entry: ${entryName}`);
|
||||
}
|
||||
|
||||
const actualDigest = sha256Hex(entryContent);
|
||||
if (actualDigest !== resolved.digest) {
|
||||
throw new Error(`Checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches text from a URL with timeout.
|
||||
*/
|
||||
export async function fetchText(url: string, timeoutMs: number = 10000): Promise<string | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await secureFetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json,text/plain;q=0.9,*/*;q=0.8' },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
// Re-throw security policy violations - these should never be silently caught
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
throw error;
|
||||
}
|
||||
// Network errors, timeouts, etc. return null (graceful degradation)
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default checksums URL from feed URL.
|
||||
*/
|
||||
export function defaultChecksumsUrl(feedUrl: string): string {
|
||||
try {
|
||||
return new URL('checksums.json', feedUrl).toString();
|
||||
} catch {
|
||||
const fallbackBase = feedUrl.replace(/\/?[^/]*$/, '');
|
||||
return `${fallbackBase}/checksums.json`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts the basename from a URL or file path.
|
||||
*/
|
||||
function _safeBasename(urlOrPath: string, fallback: string): string {
|
||||
try {
|
||||
const parsed = new URL(urlOrPath);
|
||||
const pathname = parsed.pathname;
|
||||
const lastSlash = pathname.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < pathname.length - 1) {
|
||||
return pathname.slice(lastSlash + 1);
|
||||
}
|
||||
} catch {
|
||||
const normalized = urlOrPath.trim();
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < normalized.length - 1) {
|
||||
return normalized.slice(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user