mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-19 00:11:20 +03:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| fdaa933a24 | |||
| 760e49f3e0 | |||
| 24b5bf9f1b | |||
| 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.1
|
||||
bandit==1.9.3
|
||||
+56
-17
@@ -6,13 +6,22 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
@@ -28,24 +37,22 @@ jobs:
|
||||
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@c1824fd6edce30d7ab345a9989de00bbd46ef284 # 0.34.0
|
||||
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@c1824fd6edce30d7ab345a9989de00bbd46ef284 # 0.34.0
|
||||
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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
@@ -85,3 +92,35 @@ 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
- 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: 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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Suppression Config Tests
|
||||
run: node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
|
||||
- name: Render Report Suppression Tests
|
||||
run: node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
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@9e907b5e64f6b83e7804b09294d44122997950d6 # 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@9e907b5e64f6b83e7804b09294d44122997950d6 # v4
|
||||
@@ -4,9 +4,7 @@ on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
permissions: read-all
|
||||
|
||||
concurrency:
|
||||
group: community-advisory
|
||||
@@ -14,15 +12,21 @@ 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:
|
||||
if: github.event.label.name == 'advisory-approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -196,46 +200,80 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit changes
|
||||
- name: Sign advisory feed and verify
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
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 }}
|
||||
|
||||
git add "$FEED_PATH" "$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"
|
||||
|
||||
ADVISORY_ID="${{ steps.parse.outputs.advisory_id }}"
|
||||
git commit -m "chore: add community advisory $ADVISORY_ID
|
||||
- name: Create Pull Request
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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 }}.
|
||||
|
||||
Added from issue #${{ github.event.issue.number }}
|
||||
Issue: ${{ github.event.issue.html_url }}"
|
||||
- Issue: ${{ github.event.issue.html_url }}
|
||||
- Reporter: @${{ github.event.issue.user.login }}
|
||||
- Trigger: `advisory-approved` label
|
||||
|
||||
git push
|
||||
---
|
||||
*This PR was generated by the community advisory workflow.*
|
||||
commit-message: |
|
||||
chore: add community advisory ${{ steps.parse.outputs.advisory_id }}
|
||||
|
||||
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:
|
||||
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:
|
||||
script: |
|
||||
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
||||
|
||||
@@ -3,8 +3,8 @@ name: Deploy to GitHub Pages
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI", "Skill Release"]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
# Note: No branch restriction - must trigger on both main branch CI runs AND tag-based Skill Releases
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -23,10 +23,14 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||
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 +52,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 +74,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 +103,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 +128,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 +190,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 +211,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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.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 +343,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 +379,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,10 +393,10 @@ 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
|
||||
|
||||
@@ -280,4 +410,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
|
||||
@@ -12,9 +12,7 @@ on:
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
permissions: read-all
|
||||
|
||||
concurrency:
|
||||
group: poll-nvd-cves
|
||||
@@ -22,16 +20,21 @@ 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:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -80,6 +83,7 @@ jobs:
|
||||
- name: Fetch CVEs from NVD
|
||||
id: fetch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p tmp
|
||||
|
||||
START_DATE="${{ steps.dates.outputs.start_date }}"
|
||||
@@ -90,6 +94,8 @@ 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
|
||||
@@ -99,11 +105,22 @@ jobs:
|
||||
echo "URL: $URL"
|
||||
|
||||
# Fetch with retry logic
|
||||
keyword_ok=false
|
||||
last_http_code=""
|
||||
for i in 1 2 3; do
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL")
|
||||
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
|
||||
echo "Success for $KEYWORD"
|
||||
break
|
||||
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
|
||||
@@ -112,11 +129,21 @@ jobs:
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
|
||||
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/
|
||||
|
||||
@@ -202,9 +229,79 @@ 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
|
||||
);
|
||||
|
||||
[.[] | {
|
||||
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)),
|
||||
@@ -225,6 +322,8 @@ 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.description != $nvd_entry.description) then
|
||||
{
|
||||
@@ -232,11 +331,15 @@ jobs:
|
||||
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.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,
|
||||
description: $nvd_entry.description,
|
||||
title: $nvd_entry.title,
|
||||
@@ -282,13 +385,82 @@ 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
|
||||
);
|
||||
|
||||
[.[] |
|
||||
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],
|
||||
@@ -324,7 +496,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 +535,22 @@ jobs:
|
||||
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: |
|
||||
@@ -373,7 +561,7 @@ jobs:
|
||||
- name: Create Pull Request
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: automated/nvd-cve-update-${{ github.run_id }}
|
||||
@@ -398,7 +586,9 @@ jobs:
|
||||
Poll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}
|
||||
add-paths: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.FEED_SIG_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
${{ env.SKILL_FEED_SIG_PATH }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '19 23 * * 0'
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
+966
-225
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
.codex
|
||||
_bmad
|
||||
_bmad-output
|
||||
ext-docs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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`
|
||||
- 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.
|
||||
- `./scripts/prepare-to-push.sh [--fix]`: run lint, types, build, and security checks.
|
||||
- `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 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`.
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
+62
-33
@@ -10,6 +10,7 @@ Thank you for your interest in contributing security skills to the ClawSec ecosy
|
||||
- [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 +50,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 +116,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 +146,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 +170,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 +187,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 +206,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 +302,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 +369,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 +441,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 +485,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 +506,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.
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
@@ -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,7 +46,7 @@ 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
|
||||
|
||||
---
|
||||
@@ -67,9 +72,68 @@ 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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -78,7 +142,7 @@ The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and
|
||||
| Skill | Description | Installation | Compatibility |
|
||||
|-------|-------------|--------------|---------------|
|
||||
| 📡 **clawsec-feed** | Security advisory feed monitoring with live CVE updates | ✅ Included by default | All agents |
|
||||
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with email reporting | ✅ Included by default | OpenClaw/MoltBot/ClawdBot |
|
||||
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with email reporting | ⚙️ Optional (install separately) | OpenClaw/MoltBot/ClawdBot |
|
||||
| 👻 **soul-guardian** | Drift detection and file integrity guard with auto-restore | ⚙️ Optional | All agents |
|
||||
| 🤝 **clawtributor** | Community incident reporting | ❌ Optional (Explicit request) | All agents |
|
||||
|
||||
@@ -109,9 +173,8 @@ curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[]
|
||||
### 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
|
||||
|
||||
@@ -123,6 +186,7 @@ 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",
|
||||
@@ -139,6 +203,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 +214,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
|
||||
@@ -169,11 +240,33 @@ ClawSec uses automated pipelines for continuous security updates and skill distr
|
||||
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** - Marks older versions (same major) as pre-releases
|
||||
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 +287,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:
|
||||
- [`docs/SECURITY-SIGNING.md`](docs/SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
|
||||
- [`docs/MIGRATION-SIGNED-FEED.md`](docs/MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Offline Tools
|
||||
@@ -220,16 +318,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
|
||||
|
||||
---
|
||||
@@ -326,7 +423,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#submitting-security-advisories) for detail
|
||||
|
||||
## 📄 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-----
|
||||
+551
-27
@@ -1,12 +1,553 @@
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"updated": "2026-02-05T12:53:37Z",
|
||||
"version": "0.0.3",
|
||||
"updated": "2026-02-24T06:20:16Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-27576",
|
||||
"severity": "medium",
|
||||
"type": "uncontrolled_resource_consumption",
|
||||
"nvd_category_id": "CWE-400",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the ACP bridge accepts very la...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the ACP bridge accepts very large prompt text blocks and can assemble oversized prompt payloads before forwarding them to chat.send. Because ACP runs over local stdio, this mainly affects local ACP clients (for example IDE integrations) that send unusually large inputs. This issue has been fixed in version 2026.2.19.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:13.437",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/63e39d7f57ac4ad4a5e38d17e7394ae7c4dd0b9c",
|
||||
"https://github.com/openclaw/openclaw/commit/8ae2d5110f6ceadef73822aa3db194fb60d2ba68",
|
||||
"https://github.com/openclaw/openclaw/commit/ebcf19746f5c500a41817e03abecadea8655654a"
|
||||
],
|
||||
"cvss_score": 4.0,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27576"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27488",
|
||||
"severity": "high",
|
||||
"type": "server_side_request_forgery",
|
||||
"nvd_category_id": "CWE-918",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, Cron webhook delivery in src/g...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, Cron webhook delivery in src/gateway/server-cron.ts uses fetch() directly, so webhook targets can reach private/metadata/internal endpoints without SSRF policy checks. This issue was fixed in version 2026.2.19.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:13.267",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/99db4d13e5c139883ef0def9ff963e9273179655",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.19",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-w45g-5746-x9fp"
|
||||
],
|
||||
"cvss_score": 7.3,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27488"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27487",
|
||||
"severity": "high",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.13 and below, when using macOS, the Claude C...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.13 and below, when using macOS, the Claude CLI keychain credential refresh path constructed a shell command to write the updated JSON blob into Keychain via security add-generic-password -w .... Because OAuth tokens are user-controlled data, this created an OS command injection risk. This issue has been fixed in version 2026.2.14.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:13.100",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/66d7178f2d6f9d60abad35797f97f3e61389b70c",
|
||||
"https://github.com/openclaw/openclaw/commit/9dce3d8bf83f13c067bc3c32291643d2f1f10a06",
|
||||
"https://github.com/openclaw/openclaw/commit/b908388245764fb3586859f44d1dff5372b19caf"
|
||||
],
|
||||
"cvss_score": 7.6,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27487"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27486",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_283",
|
||||
"nvd_category_id": "CWE-283",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.13 and below of the OpenClaw CLI, the proces...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.13 and below of the OpenClaw CLI, the process cleanup uses system-wide process enumeration and pattern matching to terminate processes without verifying if they are owned by the current OpenClaw process. On shared hosts, unrelated processes can be terminated if they match the pattern. The CLI runner cleanup helpers can kill processes matched by command-line patterns without validating process ownership. This issue has been fixed in version 2026.2.14.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:12.903",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/6084d13b956119e3cf95daaf9a1cae1670ea3557",
|
||||
"https://github.com/openclaw/openclaw/commit/eb60e2e1b213740c3c587a7ba4dbf10da620ca66",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14"
|
||||
],
|
||||
"cvss_score": null,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27486"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27485",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_61",
|
||||
"nvd_category_id": "CWE-61",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, skills/skill-creator/scripts/p...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, skills/skill-creator/scripts/package_skill.py (a local helper script used when authors package skills) previously followed symlinks while building .skill archives. If an author runs this script on a crafted local skill directory containing symlinks to files outside the skill root, the resulting archive can include unintended file contents. If exploited, this vulnerability can lead to potential unintentional disclosure of local files from the packaging machine into a generated .skill artifact, but requires local execution of the packaging script on attacker-controlled skill contents. This issue has been fixed in version 2026.2.18.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:12.723",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/c275932aa4230fb7a8212fe1b9d2a18424874b3f",
|
||||
"https://github.com/openclaw/openclaw/commit/ee1d6427b544ccadd73e02b1630ea5c29ba9a9f0",
|
||||
"https://github.com/openclaw/openclaw/pull/20796"
|
||||
],
|
||||
"cvss_score": 4.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27485"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27484",
|
||||
"severity": "medium",
|
||||
"type": "missing_authorization",
|
||||
"nvd_category_id": "CWE-862",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the Discord moderation action ...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the Discord moderation action handling (timeout, kick, ban) uses sender identity from request parameters in tool-driven flows, instead of trusted runtime sender context. In setups where Discord moderation actions are enabled and the bot has the necessary guild permissions, a non-admin user can request moderation actions by spoofing sender identity fields. This issue has been fixed in version 2026.2.18.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:12.557",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/775816035ecc6bb243843f8000c9a58ff609e32d",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.19",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-wh94-p5m6-mr7j"
|
||||
],
|
||||
"cvss_score": 4.3,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27484"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27009",
|
||||
"severity": "medium",
|
||||
"type": "cross_site_scripting",
|
||||
"nvd_category_id": "CWE-79",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a atored XSS issue in the OpenClaw ...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a atored XSS issue in the OpenClaw Control UI when rendering assistant identity (name/avatar) into an inline `<script>` tag without script-context-safe escaping. A crafted value containing `</script>` could break out of the script tag and execute attacker-controlled JavaScript in the Control UI origin. Version 2026.2.15 removed inline script injection and serve bootstrap config from a JSON endpoint and added a restrictive Content Security Policy for the Control UI (`script-src 'self'`, no inline scripts).",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:17.620",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/3b4096e02e7e335f99f5986ec1bd566e90b14a7e",
|
||||
"https://github.com/openclaw/openclaw/commit/adc818db4a4b3b8d663e7674ef20436947514e1b",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15"
|
||||
],
|
||||
"cvss_score": 5.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27009"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27008",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_73",
|
||||
"nvd_category_id": "CWE-73",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a bug in `download` skill installat...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a bug in `download` skill installation allowed `targetDir` values from skill frontmatter to resolve outside the per-skill tools directory if not strictly validated. In the admin-only `skills.install` flow, this could write files outside the intended install sandbox. Version 2026.2.15 contains a fix for the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:17.460",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/2363e1b0853a028e47f90dcc1066e3e9809d65f1",
|
||||
"https://github.com/openclaw/openclaw/commit/b6305e97256d67e439719faacf5af3de9727d6e1",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15"
|
||||
],
|
||||
"cvss_score": 6.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27008"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27007",
|
||||
"severity": "low",
|
||||
"type": "unknown_cwe_1254",
|
||||
"nvd_category_id": "CWE-1254",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, `normalizeForHash` in `src/agents/s...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, `normalizeForHash` in `src/agents/sandbox/config-hash.ts` recursively sorted arrays that contained only primitive values. This made order-sensitive sandbox configuration arrays hash to the same value even when order changed. In OpenClaw sandbox flows, this hash is used to decide whether existing sandbox containers should be recreated. As a result, order-only config changes (for example Docker `dns` and `binds` array order) could be treated as unchanged and stale containers could be reused. This is a configuration integrity issue affecting sandbox recreation behavior. Starting in version 2026.2.15, array ordering is preserved during hash normalization; only object key ordering remains normalized for deterministic hashing.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:17.303",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/41ded303b4f6dae5afa854531ff837c3276ad60b",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-xxvh-5hwj-42pp"
|
||||
],
|
||||
"cvss_score": 3.3,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27007"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27004",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_209",
|
||||
"nvd_category_id": "CWE-209",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, in some shared-agent deployments, O...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, in some shared-agent deployments, OpenClaw session tools (`sessions_list`, `sessions_history`, `sessions_send`) allowed broader session targeting than some operators intended. This is primarily a configuration/visibility-scoping issue in multi-user environments where peers are not equally trusted. In Telegram webhook mode, monitor startup also did not fall back to per-account `webhookSecret` when only the account-level secret was configured. In shared-agent, multi-user, less-trusted environments: session-tool access could expose transcript content across peer sessions. In single-agent or trusted environments, practical impact is limited. In Telegram webhook mode, account-level secret wiring could be missed unless an explicit monitor webhook secret override was provided. Version 2026.2.15 fixes the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:17.140",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/c6c53437f7da033b94a01d492e904974e7bda74c",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-6hf3-mhgc-cm65"
|
||||
],
|
||||
"cvss_score": 5.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27004"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27003",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_522",
|
||||
"nvd_category_id": "CWE-522",
|
||||
"title": "OpenClaw is a personal AI assistant. Telegram bot tokens can appear in error messages and stack trac...",
|
||||
"description": "OpenClaw is a personal AI assistant. Telegram bot tokens can appear in error messages and stack traces (for example, when request URLs include `https://api.telegram.org/bot<token>/...`). Prior to version 2026.2.15, OpenClaw logged these strings without redaction, which could leak the bot token into logs, crash reports, CI output, or support bundles. Disclosure of a Telegram bot token allows an attacker to impersonate the bot and take over Bot API access. Users should upgrade to version 2026.2.15 to obtain a fix and rotate the Telegram bot token if it may have been exposed.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:16.983",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/cf69907015b659e5025efb735ee31bd05c4ee3d5",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-chf7-jq6g-qrwv"
|
||||
],
|
||||
"cvss_score": 5.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27003"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27002",
|
||||
"severity": "critical",
|
||||
"type": "execution_with_unnecessary_privileges",
|
||||
"nvd_category_id": "CWE-250",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a configuration injection issue in ...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a configuration injection issue in the Docker tool sandbox could allow dangerous Docker options (bind mounts, host networking, unconfined profiles) to be applied, enabling container escape or host data access. OpenClaw 2026.2.15 blocks dangerous sandbox Docker settings and includes runtime enforcement when building `docker create` args; config-schema validation for `network=host`, `seccompProfile=unconfined`, `apparmorProfile=unconfined`; and security audit findings to surface dangerous sandbox docker config. As a workaround, do not configure `agents.*.sandbox.docker.binds` to mount system directories or Docker socket paths, keep `agents.*.sandbox.docker.network` at `none` (default) or `bridge`, and do not use `unconfined` for seccomp/AppArmor profiles.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:16.827",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/887b209db47f1f9322fead241a1c0b043fd38339",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-w235-x559-36mg"
|
||||
],
|
||||
"cvss_score": 9.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27002"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27001",
|
||||
"severity": "high",
|
||||
"type": "command_injection",
|
||||
"nvd_category_id": "CWE-77",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, OpenClaw embedded the current worki...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, OpenClaw embedded the current working directory (workspace path) into the agent system prompt without sanitization. If an attacker can cause OpenClaw to run inside a directory whose name contains control/format characters (for example newlines or Unicode bidi/zero-width markers), those characters could break the prompt structure and inject attacker-controlled instructions. Starting in version 2026.2.15, the workspace path is sanitized before it is embedded into any LLM prompt output, stripping Unicode control/format characters and explicit line/paragraph separators. Workspace path resolution also applies the same sanitization as defense-in-depth.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:16.653",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/6254e96acf16e70ceccc8f9b2abecee44d606f79",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-2qj5-gwg2-xwc4"
|
||||
],
|
||||
"cvss_score": 7.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27001"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26972",
|
||||
"severity": "medium",
|
||||
"type": "path_traversal",
|
||||
"nvd_category_id": "CWE-22",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.1.12 through 2026.2.12, OpenClaw browser downl...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.1.12 through 2026.2.12, OpenClaw browser download helpers accepted an unsanitized output path. When invoked via the browser control gateway routes, this allowed path traversal to write downloads outside the intended OpenClaw temp downloads directory. This issue is not exposed via the AI agent tool schema (no `download` action). Exploitation requires authenticated CLI access or an authenticated gateway RPC token. Version 2026.2.13 fixes the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:16.500",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/7f0489e4731c8d965d78d6eac4a60312e46a9426",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.13",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-xwjm-j929-xq7c"
|
||||
],
|
||||
"cvss_score": 6.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26972"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26329",
|
||||
"severity": "medium",
|
||||
"type": "path_traversal",
|
||||
"nvd_category_id": "CWE-22",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, authenticated attackers can read ar...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, authenticated attackers can read arbitrary files from the Gateway host by supplying absolute paths or path traversal sequences to the browser tool's `upload` action. The server passed these paths to Playwright's `setInputFiles()` APIs without restricting them to a safe root. An attacker must reach the Gateway HTTP surface (or otherwise invoke the same browser control hook endpoints); present valid Gateway auth (bearer token / password), as required by the Gateway configuration (In common default setups, the Gateway binds to loopback and the onboarding wizard generates a gateway token even for loopback); and have the `browser` tool permitted by tool policy for the target session/context (and have browser support enabled). If an operator exposes the Gateway beyond loopback (LAN/tailnet/custom bind, reverse proxy, tunnels, etc.), the impact increases accordingly. Starting in version 2026.2.14, the upload paths are now confined to OpenClaw's temp uploads root (`DEFAULT_UPLOAD_DIR`) and traversal/escape paths are rejected.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:15.687",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/3aa94afcfd12104c683c9cad81faf434d0dadf87",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-cv7m-c9jx-vg7q"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26329"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26328",
|
||||
"severity": "medium",
|
||||
"type": "improper_access_control",
|
||||
"nvd_category_id": "CWE-284",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, under iMessage `groupPolicy=allowli...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, under iMessage `groupPolicy=allowlist`, group authorization could be satisfied by sender identities coming from the DM pairing store, broadening DM trust into group contexts. Version 2026.2.14 fixes the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:15.523",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/872079d42fe105ece2900a1dd6ab321b92da2d59",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g34w-4xqq-h79m"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26328"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26327",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_345",
|
||||
"nvd_category_id": "CWE-345",
|
||||
"title": "OpenClaw is a personal AI assistant. Discovery beacons (Bonjour/mDNS and DNS-SD) include TXT records...",
|
||||
"description": "OpenClaw is a personal AI assistant. Discovery beacons (Bonjour/mDNS and DNS-SD) include TXT records such as `lanHost`, `tailnetDns`, `gatewayPort`, and `gatewayTlsSha256`. TXT records are unauthenticated. Prior to version 2026.2.14, some clients treated TXT values as authoritative routing/pinning inputs. iOS and macOS used TXT-provided host hints (`lanHost`/`tailnetDns`) and ports (`gatewayPort`) to build the connection URL. iOS and Android allowed the discovery-provided TLS fingerprint (`gatewayTlsSha256`) to override a previously stored TLS pin. On a shared/untrusted LAN, an attacker could advertise a rogue `_openclaw-gw._tcp` service. This could cause a client to connect to an attacker-controlled endpoint and/or accept an attacker certificate, potentially exfiltrating Gateway credentials (`auth.token` / `auth.password`) during connection. As of time of publication, the iOS and Android apps are alpha/not broadly shipped (no public App Store / Play Store release). Practical impact is primarily limited to developers/testers running those builds, plus any other shipped clients relying on discovery on a shared/untrusted LAN. Version 2026.2.14 fixes the issue. Clients now prefer the resolved service endpoint (SRV + A/AAAA) over TXT-provided routing hints. Discovery-provided fingerprints no longer override stored TLS pins. In iOS/Android, first-time TLS pins require explicit user confirmation (fingerprint shown; no silent TOFU) and discovery-based direct connects are TLS-only. In Android, hostname verification is no longer globally disabled (only bypassed when pinning).",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:26.100",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/d583782ee322a6faa1fe87ae52455e0d349de586",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-pv58-549p-qh99"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26327"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26326",
|
||||
"severity": "medium",
|
||||
"type": "exposure_of_sensitive_information",
|
||||
"nvd_category_id": "CWE-200",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, `skills.status` could disclose secr...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, `skills.status` could disclose secrets to `operator.read` clients by returning raw resolved config values in `configChecks` for skill `requires.config` paths. Version 2026.2.14 stops including raw resolved config values in requirement checks (return only `{ path, satisfied }`) and narrows the Discord skill requirement to the token key. In addition to upgrading, users should rotate any Discord tokens that may have been exposed to read-scoped clients.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.950",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/d3428053d95eefbe10ecf04f92218ffcba55ae5a",
|
||||
"https://github.com/openclaw/openclaw/commit/ebc68861a61067fc37f9298bded3eec9de0ba783",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14"
|
||||
],
|
||||
"cvss_score": 4.3,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26326"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26325",
|
||||
"severity": "high",
|
||||
"type": "improper_access_control",
|
||||
"nvd_category_id": "CWE-284",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, a mismatch between `rawCommand` and...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, a mismatch between `rawCommand` and `command[]` in the node host `system.run` handler could cause allowlist/approval evaluation to be performed on one command while executing a different argv. This only impacts deployments that use the node host / companion node execution path (`system.run` on a node), enable allowlist-based exec policy (`security=allowlist`) with approval prompting driven by allowlist misses (for example `ask=on-miss`), allow an attacker to invoke `system.run`. Default/non-node configurations are not affected. Version 2026.2.14 enforces `rawCommand`/`command[]` consistency (gateway fail-fast + node host validation).",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.800",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/cb3290fca32593956638f161d9776266b90ab891",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-h3f9-mjwj-w476"
|
||||
],
|
||||
"cvss_score": 7.2,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26325"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26324",
|
||||
"severity": "high",
|
||||
"type": "server_side_request_forgery",
|
||||
"nvd_category_id": "CWE-918",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, OpenClaw's SSRF protection could be...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, OpenClaw's SSRF protection could be bypassed using full-form IPv4-mapped IPv6 literals such as `0:0:0:0:0:ffff:7f00:1` (which is `127.0.0.1`). This could allow requests that should be blocked (loopback / private network / link-local metadata) to pass the SSRF guard. Version 2026.2.14 patches the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.653",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/c0c0e0f9aecb913e738742f73e091f2f72d39a19",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-jrvc-8ff5-2f9f"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26324"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26323",
|
||||
"severity": "high",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw is a personal AI assistant. Versions 2026.1.8 through 2026.2.13 have a command injection in...",
|
||||
"description": "OpenClaw is a personal AI assistant. Versions 2026.1.8 through 2026.2.13 have a command injection in the maintainer/dev script `scripts/update-clawtributors.ts`. The issue affects contributors/maintainers (or CI) who run `bun scripts/update-clawtributors.ts` in a source checkout that contains a malicious commit author email (e.g. crafted `@users[.]noreply[.]github[.]com` values). Normal CLI usage is not affected (`npm i -g openclaw`): this script is not part of the shipped CLI and is not executed during routine operation. The script derived a GitHub login from `git log` author metadata and interpolated it into a shell command (via `execSync`). A malicious commit record could inject shell metacharacters and execute arbitrary commands when the script is run. Version 2026.2.14 contains a patch.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.500",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/a429380e337152746031d290432a4b93aa553d55",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-m7x8-2w3w-pr42"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26323"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26322",
|
||||
"severity": "high",
|
||||
"type": "server_side_request_forgery",
|
||||
"nvd_category_id": "CWE-918",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to OpenClaw version 2026.2.14, the Gateway tool accepted ...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to OpenClaw version 2026.2.14, the Gateway tool accepted a tool-supplied `gatewayUrl` without sufficient restrictions, which could cause the OpenClaw host to attempt outbound WebSocket connections to user-specified targets. This requires the ability to invoke tools that accept `gatewayUrl` overrides (directly or indirectly). In typical setups this is limited to authenticated operators, trusted automation, or environments where tool calls are exposed to non-operators. In other words, this is not a drive-by issue for arbitrary internet users unless a deployment explicitly allows untrusted users to trigger these tool calls. Some tool call paths allowed `gatewayUrl` overrides to flow into the Gateway WebSocket client without validation or allowlisting. This meant the host could be instructed to attempt connections to non-gateway endpoints (for example, localhost services, private network addresses, or cloud metadata IPs). In the common case, this results in an outbound connection attempt from the OpenClaw host (and corresponding errors/timeouts). In environments where the tool caller can observe the results, this can also be used for limited network reachability probing. If the target speaks WebSocket and is reachable, further interaction may be possible. Starting in version 2026.2.14, tool-supplied `gatewayUrl` overrides are restricted to loopback (on the configured gateway port) or the configured `gateway.remote.url`. Disallowed protocols, credentials, query/hash, and non-root paths are rejected.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.340",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/c5406e1d2434be2ef6eb4d26d8f1798d718713f4",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g6q9-8fvw-f7rf"
|
||||
],
|
||||
"cvss_score": 7.6,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26322"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26321",
|
||||
"severity": "high",
|
||||
"type": "path_traversal",
|
||||
"nvd_category_id": "CWE-22",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to OpenClaw version 2026.2.14, the Feishu extension previ...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to OpenClaw version 2026.2.14, the Feishu extension previously allowed `sendMediaFeishu` to treat attacker-controlled `mediaUrl` values as local filesystem paths and read them directly. If an attacker can influence tool calls (directly or via prompt injection), they may be able to exfiltrate local files by supplying paths such as `/etc/passwd` as `mediaUrl`. Upgrade to OpenClaw `2026.2.14` or newer to receive a fix. The fix removes direct local file reads from this path and routes media loading through hardened helpers that enforce local-root restrictions.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.180",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/5b4121d6011a48c71e747e3c18197f180b872c5d",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-8jpq-5h99-ff5r"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26321"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26320",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_451",
|
||||
"nvd_category_id": "CWE-451",
|
||||
"title": "OpenClaw is a personal AI assistant. OpenClaw macOS desktop client registers the `openclaw://` URL s...",
|
||||
"description": "OpenClaw is a personal AI assistant. OpenClaw macOS desktop client registers the `openclaw://` URL scheme. For `openclaw://agent` deep links without an unattended `key`, the app shows a confirmation dialog that previously displayed only the first 240 characters of the message, but executed the full message after the user clicked \"Run.\" At the time of writing, the OpenClaw macOS desktop client is still in beta. In versions 2026.2.6 through 2026.2.13, an attacker could pad the message with whitespace to push a malicious payload outside the visible preview, increasing the chance a user approves a different message than the one that is actually executed. If a user runs the deep link, the agent may perform actions that can lead to arbitrary command execution depending on the user's configured tool approvals/allowlists. This is a social-engineering mediated vulnerability: the confirmation prompt could be made to misrepresent the executed message. The issue is fixed in 2026.2.14. Other mitigations include not approve unexpected \"Run OpenClaw agent?\" prompts triggered while browsing untrusted sites and usingunattended deep links only with a valid `key` for trusted personal automations.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.017",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/28d9dd7a772501ccc3f71457b4adfee79084fe6f",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-7q2j-c4q5-rm27"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26320"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26319",
|
||||
"severity": "high",
|
||||
"type": "missing_authentication_for_critical_function",
|
||||
"nvd_category_id": "CWE-306",
|
||||
"title": "OpenClaw is a personal AI assistant. Versions 2026.2.13 and below allow the optional @openclaw/voice...",
|
||||
"description": "OpenClaw is a personal AI assistant. Versions 2026.2.13 and below allow the optional @openclaw/voice-call plugin Telnyx webhook handler to accept unsigned inbound webhook requests when telnyx.publicKey is not configured, enabling unauthenticated callers to forge Telnyx events. Telnyx webhooks are expected to be authenticated via Ed25519 signature verification. In affected versions, TelnyxProvider.verifyWebhook() could effectively fail open when no Telnyx public key was configured, allowing arbitrary HTTP POST requests to the voice-call webhook endpoint to be treated as legitimate Telnyx events. This only impacts deployments where the Voice Call plugin is installed, enabled, and the webhook endpoint is reachable from the attacker (for example, publicly exposed via a tunnel/proxy). The issue has been fixed in version 2026.2.14.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:24.857",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/29b587e73cbdc941caec573facd16e87d52f007b",
|
||||
"https://github.com/openclaw/openclaw/commit/f47584fec86d6d73f2d483043a2ad0e7e3c50411",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26319"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26317",
|
||||
"severity": "high",
|
||||
"type": "cross_site_request_forgery",
|
||||
"nvd_category_id": "CWE-352",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.2.14, browser-facing localhost mutation routes ac...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.2.14, browser-facing localhost mutation routes accepted cross-origin browser requests without explicit Origin/Referer validation. Loopback binding reduces remote exposure but does not prevent browser-initiated requests from malicious origins. A malicious website can trigger unauthorized state changes against a victim's local OpenClaw browser control plane (for example opening tabs, starting/stopping the browser, mutating storage/cookies) if the browser control service is reachable on loopback in the victim's browser context. Starting in version 2026.2.14, mutating HTTP methods (POST/PUT/PATCH/DELETE) are rejected when the request indicates a non-loopback Origin/Referer (or `Sec-Fetch-Site: cross-site`). Other mitigations include enabling browser control auth (token/password) and avoid running with auth disabled.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T22:16:47.270",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/b566b09f81e2b704bf9398d8d97d5f7a90aa94c3",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-3fqr-4cg8-h96q"
|
||||
],
|
||||
"cvss_score": 7.1,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26317"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26316",
|
||||
"severity": "high",
|
||||
"type": "incorrect_authorization",
|
||||
"nvd_category_id": "CWE-863",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.2.13, the optional BlueBubbles iMessage channel p...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.2.13, the optional BlueBubbles iMessage channel plugin could accept webhook requests as authenticated based only on the TCP peer address being loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) even when the configured webhook secret was missing or incorrect. This does not affect the default iMessage integration unless BlueBubbles is installed and enabled. Version 2026.2.13 contains a patch. Other mitigations include setting a non-empty BlueBubbles webhook password and avoiding deployments where a public-facing reverse proxy forwards to a loopback-bound Gateway without strong upstream authentication.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T22:16:47.110",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/743f4b28495cdeb0d5bf76f6ebf4af01f6a02e5a",
|
||||
"https://github.com/openclaw/openclaw/commit/f836c385ffc746cb954e8ee409f99d079bfdcd2f",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.13"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26316"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25474",
|
||||
"severity": "high",
|
||||
"type": "unknown_cwe_345",
|
||||
"nvd_category_id": "CWE-345",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.1.30 and below, if channels.telegram.webhookSe...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.1.30 and below, if channels.telegram.webhookSecret is not set when in Telegram webhook mode, OpenClaw may accept webhook HTTP requests without verifying Telegram’s secret token header. In deployments where the webhook endpoint is reachable by an attacker, this can allow forged Telegram updates (for example spoofing message.from.id). If an attacker can reach the webhook endpoint, they may be able to send forged updates that are processed as if they came from Telegram. Depending on enabled commands/tools and configuration, this could lead to unintended bot actions. Note: Telegram webhook mode is not enabled by default. It is enabled only when `channels.telegram.webhookUrl` is configured. This issue has been fixed in version 2026.2.1.",
|
||||
"affected": [
|
||||
"cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*"
|
||||
],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T07:17:45.847",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/3cbcba10cf30c2ffb898f0d8c7dfb929f15f8930",
|
||||
"https://github.com/openclaw/openclaw/commit/5643a934799dc523ec2ef18c007e1aa2c386b670",
|
||||
"https://github.com/openclaw/openclaw/commit/633fe8b9c17f02fcc68ecdb5ec212a5ace932f09"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25474"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24764",
|
||||
"severity": "low",
|
||||
"type": "unknown_cwe_74",
|
||||
"nvd_category_id": "CWE-74",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant users run on their own devices. In versions ...",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant users run on their own devices. In versions 2026.2.2 and below, when the Slack integration is enabled, channel metadata (topic/description) can be incorporated into the model's system prompt. Prompt injection is a documented risk for LLM-driven systems. This issue increases the injection surface by allowing untrusted Slack channel metadata to be treated as higher-trust system input. This issue has been fixed in version 2026.2.3.",
|
||||
"affected": [
|
||||
"cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*"
|
||||
],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T07:17:44.957",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/35eb40a7000b59085e9c638a80fd03917c7a095e",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.3",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-782p-5fr5-7fj8"
|
||||
],
|
||||
"cvss_score": 3.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24764"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "missing_authentication_for_critical_function",
|
||||
"nvd_category_id": "CWE-306",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "exposure_of_sensitive_information",
|
||||
"nvd_category_id": "CWE-200",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.",
|
||||
"affected": [],
|
||||
@@ -21,7 +562,8 @@
|
||||
{
|
||||
"id": "CVE-2026-25157",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.",
|
||||
"affected": [],
|
||||
@@ -33,32 +575,13 @@
|
||||
"cvss_score": 7.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157"
|
||||
},
|
||||
{
|
||||
"id": "CLAW-2026-0001",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection",
|
||||
"title": "Data exfiltration attempt via helper-plus skill",
|
||||
"description": "The helper-plus skill was observed sending conversation data to an external server (suspicious-domain.com) on every invocation. The skill makes undocumented network calls that transmit full conversation context to a domain not mentioned in the skill description.",
|
||||
"affected": [
|
||||
"helper-plus@1.0.0",
|
||||
"helper-plus@1.0.1"
|
||||
],
|
||||
"action": "Remove helper-plus immediately. Do not use versions 1.0.0 or 1.0.1. Wait for a verified patched version.",
|
||||
"published": "2026-02-04T09:30:00Z",
|
||||
"references": [],
|
||||
"source": "Community Report",
|
||||
"github_issue_url": "https://github.com/prompt-security/clawsec/issues/1",
|
||||
"reporter": {
|
||||
"agent_name": "SecurityBot",
|
||||
"opener_type": "agent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24763",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw's Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
@@ -73,7 +596,8 @@
|
||||
{
|
||||
"id": "CVE-2026-25253",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "incorrect_resource_transfer_between_spheres",
|
||||
"nvd_category_id": "CWE-669",
|
||||
"title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...",
|
||||
"description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.",
|
||||
"affected": [],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Rs++ntJvBvX4zVTJ/DsrfXOQG3VTUc2x4esSURSMonesmYzSm9U9kd3rBz5d+DemJOVJ/esH21VACpdE+T34AA==
|
||||
@@ -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}
|
||||
|
||||
+21
-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 } from 'lucide-react';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -52,19 +52,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">
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// 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';
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Cross-Platform Compatibility Report
|
||||
|
||||
## 1) Executive Summary
|
||||
|
||||
### Overall status by OS
|
||||
- Linux: **Good**, primary workflows validated; still some POSIX-only scripts/docs.
|
||||
- macOS: **Good**, with caveats around POSIX tool availability and Homebrew-specific assumptions.
|
||||
- Windows: **Partial**, Node/Python pieces work, but many shell-first install/release workflows still require WSL/Git Bash.
|
||||
|
||||
### Highest-risk incompatibilities
|
||||
1. **(Fixed)** Literal `$HOME` path creation risk in audit watchdog cron setup payload generation.
|
||||
2. **(Fixed)** Path env vars accepted as raw strings in multiple Node entrypoints without expansion/validation.
|
||||
3. **(Open)** Large portions of manual install/release guidance remain POSIX-only (`bash`, `jq`, `curl`, `unzip`, `chmod`, `find -exec`).
|
||||
|
||||
### SKILLS install path-expansion root cause
|
||||
Root cause was a combination of:
|
||||
- shell-side literal env assignment (for example, `PROMPTSEC_INSTALL_DIR='$HOME/...')`
|
||||
- Node scripts not expanding home tokens
|
||||
- cron payload construction escaping `$` (`\$HOME`), forcing literal interpretation in downstream shell execution
|
||||
|
||||
This could produce paths like `~/.openclaw/workspace/$HOME/...`.
|
||||
|
||||
---
|
||||
|
||||
## 2) Findings Table
|
||||
|
||||
| ID | Severity | OS Impact | Component | Description | Proposed Fix | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| CP-001 | Blocker | Linux/macOS/Windows | `skills/openclaw-audit-watchdog/scripts/setup_cron.mjs` | Literal `$HOME` could be propagated into cron payload, creating wrong runtime paths. | Expand/normalize home tokens and reject unresolved escaped tokens before job creation. | **Fixed** |
|
||||
| CP-002 | High | Linux/macOS/Windows | `skills/clawsec-suite/hooks/.../handler.ts`, `.../scripts/guarded_skill_install.mjs`, `.../lib/suppression.mjs`, `skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs` | Env path vars treated as opaque strings; `~`, `$HOME` not consistently handled. | Shared/consistent path resolution + fail-fast validation. | **Fixed** |
|
||||
| CP-003 | Medium | macOS/Windows | `skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs`, `.../scripts/codex_review.sh` | Hardcoded `/opt/homebrew` and `which` assumptions. | Use `process.execPath` for tests; PATH-first Codex discovery. | **Fixed** |
|
||||
| CP-004 | Medium | Windows (+ CI) | repo-wide line endings | Missing `.gitattributes` could introduce CRLF script breakage (`env bash^M`). | Add `.gitattributes` with LF enforcement for scripts/config/text. | **Fixed** |
|
||||
| CP-005 | Medium | macOS/Windows | `.github/workflows/ci.yml` | TS/lint/build checks were Linux-only. | Add OS matrix for Node checks (`ubuntu`, `macos`, `windows`). | **Fixed** |
|
||||
| CP-006 | High | Windows | Multiple SKILL docs and shell scripts | Install/maintenance flow is still heavily POSIX-shell based. | Add PowerShell equivalents or Node wrappers for critical flows. | Open |
|
||||
| CP-007 | Medium | Linux/macOS/Windows | `skills/soul-guardian/scripts/soul_guardian.py` | `Path(...).expanduser()` handles `~` but not `$HOME`/`%USERPROFILE%`. | Add explicit env-token expansion + validation for `--state-dir`. | Open |
|
||||
| CP-008 | Medium | Windows | `scripts/release-skill.sh`, `scripts/populate-local-*.sh` | GNU/BSD shell toolchain assumptions block native Windows usage. | Provide cross-platform Node/Python replacements or PowerShell equivalents. | Open |
|
||||
| CP-009 | Low | Windows | docs + scripts using `chmod 600/644` | POSIX permission semantics are partial/non-portable on Windows. | Document best-effort behavior and Windows ACL alternatives. | Open |
|
||||
| CP-010 | Low | macOS/Windows | CI non-Node jobs | Shell/Python/security scan jobs remain Ubuntu-only. | Add scoped matrix or dedicated non-Linux smoke jobs where practical. | Open |
|
||||
|
||||
---
|
||||
|
||||
## 3) Detailed Findings
|
||||
|
||||
## Paths
|
||||
- Fixed: centralized home-token expansion and suspicious token rejection for critical runtime/install path env vars.
|
||||
- Fixed: path normalization before filesystem access and before cron payload construction.
|
||||
- Open: `soul_guardian.py` still expands only `~`, not `$HOME`/Windows env tokens.
|
||||
|
||||
## Shell / Command Dependencies
|
||||
- Confirmed extensive POSIX dependencies (`bash`, `curl`, `jq`, `mktemp`, `chmod`, `find`, `unzip`, `openssl`, `shasum/sha256sum`).
|
||||
- Fixed minor hardcoded binary path assumptions.
|
||||
- Open: no full native PowerShell parity for core shell workflows.
|
||||
|
||||
## Permissions / Filesystem Semantics
|
||||
- Confirmed many scripts rely on POSIX permission commands.
|
||||
- Existing `state.ts` already handles `chmod` failures on unsupported filesystems.
|
||||
- Open: docs still mostly assume POSIX permissions.
|
||||
|
||||
## Line Endings
|
||||
- Fixed by adding `.gitattributes` with LF rules for scripts and key text/config files.
|
||||
|
||||
## Runtime Dependencies
|
||||
- Node scripts generally portable.
|
||||
- Python utilities are portable.
|
||||
- OpenSSL usage in docs/workflows remains shell/toolchain dependent.
|
||||
|
||||
## CI / Automation
|
||||
- Fixed: TS/lint/build matrix now runs on Linux/macOS/Windows.
|
||||
- Open: remaining security/shell/python jobs are Linux-only by design.
|
||||
|
||||
---
|
||||
|
||||
## 4) SKILLS Install Investigation
|
||||
|
||||
### Reproduction (pre-fix)
|
||||
1. Set install dir with literal token (common quoting mistake):
|
||||
- `export PROMPTSEC_INSTALL_DIR='$HOME/.config/security-checkup'`
|
||||
2. Run:
|
||||
- `node skills/openclaw-audit-watchdog/scripts/setup_cron.mjs`
|
||||
3. The generated payload command used escaped `$` in `cd` path, resulting in literal token usage at execution time (`cd "\$HOME/..."`), which can resolve under current working directory (for example, `~/.openclaw/workspace/$HOME/...`).
|
||||
|
||||
### Root cause analysis
|
||||
- POSIX single quotes prevent variable expansion.
|
||||
- Node does not auto-expand env vars inside strings.
|
||||
- Existing payload escaping converted `$` to literal in shell command text.
|
||||
|
||||
### Fix implemented
|
||||
- Added explicit path resolution (supports `~`, `$HOME`, `${HOME}`, `%USERPROFILE%`, `$env:USERPROFILE`) and normalization.
|
||||
- Added fail-fast validation for unresolved/escaped home tokens.
|
||||
- Applied to watchdog cron setup, watchdog suppression config loader, suite hook handler, suite advisory suppression loader, and suite guarded installer.
|
||||
- Added tests covering expansion and escaped-token rejection.
|
||||
|
||||
### Validation targets
|
||||
- `bash` / `zsh`: expanded env values and reject literal escaped home tokens.
|
||||
- `sh` (where scripts are invoked through Node entrypoints): same path behavior in Node layer.
|
||||
- Windows PowerShell: `%USERPROFILE%` / `$env:USERPROFILE` expansion and path normalization validated in Node tests.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Migration Plan: Unsigned Feed → Signed Feed
|
||||
|
||||
## 1) Objective
|
||||
|
||||
Move ClawSec advisory distribution from unsigned `feed.json` delivery to detached-signature verification with minimal disruption.
|
||||
|
||||
This plan is written against the current repository behavior:
|
||||
- feed is produced by `poll-nvd-cves.yml` and `community-advisory.yml`
|
||||
- feed is published by `deploy-pages.yml`
|
||||
- suite consumers currently load unsigned JSON from remote/local fallback paths
|
||||
|
||||
## 2) Baseline (today)
|
||||
|
||||
Current feed paths in active use:
|
||||
- Source of truth: `advisories/feed.json`
|
||||
- Skill copy: `skills/clawsec-feed/advisories/feed.json`
|
||||
- Pages copy: `public/advisories/feed.json`
|
||||
- Latest mirror copy: `public/releases/latest/download/advisories/feed.json`
|
||||
|
||||
Current consumer defaults:
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||
- default URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
|
||||
## 3) Migration principles
|
||||
|
||||
- **Dual-publish first**: publish signatures before enforcing verification.
|
||||
- **Fail-open only during transition**: temporary compatibility period is explicit and time-bounded.
|
||||
- **Measured rollout**: enforce verification after telemetry confirms stable signed publishing.
|
||||
- **Fast rollback**: preserve a path back to unsigned behavior while root cause is investigated.
|
||||
|
||||
## 4) Phased timeline
|
||||
|
||||
### Phase 0 — Preparation (Week 0)
|
||||
|
||||
Deliverables:
|
||||
- signing keys generated and fingerprints recorded
|
||||
- GitHub secrets created
|
||||
- public key(s) added in repo
|
||||
- runbooks approved (`SECURITY-SIGNING.md`, this file)
|
||||
|
||||
Exit criteria:
|
||||
- key fingerprints verified by reviewer
|
||||
- protected branch/workflow controls enabled
|
||||
|
||||
### Phase 1 — CI signing enabled, no client enforcement (Week 1)
|
||||
|
||||
Implement:
|
||||
- add feed signing step/workflow to produce `advisories/feed.json.sig`
|
||||
- optionally produce `advisories/checksums.json` + `.sig`
|
||||
- ensure CI verifies signatures before publishing artifacts
|
||||
|
||||
Also update deployment:
|
||||
- copy `.sig` artifacts to `public/advisories/`
|
||||
- mirror `.sig` in `public/releases/latest/download/advisories/`
|
||||
|
||||
Exit criteria:
|
||||
- signatures generated successfully for all feed update paths
|
||||
- deploy artifacts contain both payload and signature companions
|
||||
|
||||
### Phase 2 — Consumer dual-read/dual-verify support (Week 2)
|
||||
|
||||
Implement in consumers:
|
||||
- read `feed.json` and `feed.json.sig`
|
||||
- verify with pinned public key
|
||||
- keep controlled temporary unsigned fallback during migration window
|
||||
|
||||
Validation:
|
||||
- test remote signed path
|
||||
- test local signed fallback path
|
||||
- test invalid signature rejection
|
||||
|
||||
Exit criteria:
|
||||
- verification logic released and tested
|
||||
- no false-positive verification failures in soak period
|
||||
|
||||
### Phase 3 — Enforcement (Week 3)
|
||||
|
||||
Actions:
|
||||
- disable temporary unsigned fallback behavior in default paths
|
||||
- add CI/publish gates that fail when `.sig` is missing
|
||||
- announce enforcement date in release notes and docs
|
||||
|
||||
Exit criteria:
|
||||
- all production clients verify signatures by default
|
||||
- no unsigned feed dependency in standard installation flow
|
||||
|
||||
### Phase 4 — Stabilization (Week 4)
|
||||
|
||||
Actions:
|
||||
- run first key rotation tabletop drill
|
||||
- run rollback tabletop drill
|
||||
- close migration with post-implementation review
|
||||
|
||||
## 5) Rollback plan
|
||||
|
||||
### Rollback triggers
|
||||
|
||||
Initiate rollback if any of the following occur:
|
||||
- sustained signature verification failures across clients
|
||||
- signing workflow cannot produce valid signatures
|
||||
- key compromise suspected but replacement key is not yet deployed
|
||||
- deployment path publishes mismatched payload/signature pairs
|
||||
|
||||
### Rollback levels
|
||||
|
||||
### Level 1 (preferred): Verification bypass window, keep signed publishing
|
||||
|
||||
Use when: signing is healthy, client-side verifier has a defect.
|
||||
|
||||
Actions:
|
||||
1. Re-enable temporary unsigned-acceptance behavior in client release branch.
|
||||
2. Ship patch release with explicit expiry date for bypass.
|
||||
3. Keep signing pipeline active to avoid authenticity gap.
|
||||
|
||||
Recovery target: restore strict verification within 24–48h.
|
||||
|
||||
### Level 2: Signed pipeline paused, unsigned feed temporarily authoritative
|
||||
|
||||
Use when: signing pipeline is unstable or producing inconsistent artifacts.
|
||||
|
||||
Actions:
|
||||
1. Disable signing workflow or signing step.
|
||||
2. Continue publishing unsigned `advisories/feed.json` via existing workflows.
|
||||
3. Revert deploy gates that require `.sig` artifacts.
|
||||
4. Open incident record and track time in unsigned mode.
|
||||
|
||||
Recovery target: restore signed publishing ASAP, ideally <72h.
|
||||
|
||||
### Level 3: Full release freeze
|
||||
|
||||
Use when: compromise or integrity of repository/workflows is in doubt.
|
||||
|
||||
Actions:
|
||||
1. Pause feed mutation and deployment workflows.
|
||||
2. Restore known-good commit for advisory files/workflows.
|
||||
3. Rotate keys and credentials.
|
||||
4. Resume pipeline only after security review sign-off.
|
||||
|
||||
### Roll-forward after rollback
|
||||
|
||||
- identify root cause
|
||||
- add regression tests/gates
|
||||
- redeploy signed artifacts
|
||||
- publish incident + remediation summary
|
||||
|
||||
## 6) Communication plan
|
||||
|
||||
For enforcement and rollback events, communicate:
|
||||
- what changed
|
||||
- expected operator/client action
|
||||
- duration of temporary compatibility mode (if any)
|
||||
- verification commands for users
|
||||
|
||||
Recommended channels:
|
||||
- GitHub release notes
|
||||
- repository README/docs updates
|
||||
- issue/incident report in repository
|
||||
|
||||
## 7) Go/No-Go checklist
|
||||
|
||||
Go only if all are true:
|
||||
- signing workflow success rate is stable
|
||||
- signatures are mirrored to all documented feed endpoints
|
||||
- consumer verification path tested for remote + local fallback
|
||||
- rollback owner is assigned and reachable
|
||||
- key rotation procedure has been dry-run at least once
|
||||
@@ -0,0 +1,87 @@
|
||||
# Platform Verification Checklist
|
||||
|
||||
Use this checklist to validate portability and path-handling behavior after changes.
|
||||
|
||||
## Linux Verification
|
||||
|
||||
1. Run core Node tests:
|
||||
```bash
|
||||
node skills/clawsec-suite/test/path_resolution.test.mjs
|
||||
node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
node skills/clawsec-suite/test/advisory_suppression.test.mjs
|
||||
node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs
|
||||
```
|
||||
Expected: all tests pass.
|
||||
|
||||
2. Verify no literal `$HOME` path acceptance:
|
||||
```bash
|
||||
CLAWSEC_LOCAL_FEED='\$HOME/advisories/feed.json' \
|
||||
node skills/clawsec-suite/scripts/guarded_skill_install.mjs --skill test-skill --dry-run
|
||||
```
|
||||
Expected: exits non-zero with `Unexpanded home token` error.
|
||||
|
||||
3. Verify `$HOME` expansion works:
|
||||
```bash
|
||||
HOME=/tmp/clawsec-home node skills/clawsec-suite/test/path_resolution.test.mjs
|
||||
```
|
||||
Expected: `$HOME` expansion tests pass.
|
||||
|
||||
## macOS Verification
|
||||
|
||||
1. Run the same Node test suite as Linux.
|
||||
2. Confirm OpenSSL tooling path assumptions are documented:
|
||||
- If using LibreSSL/OpenSSL variations, ensure checks use tested command forms from docs.
|
||||
3. Verify tilde expansion in config path:
|
||||
```bash
|
||||
OPENCLAW_AUDIT_CONFIG=~/.openclaw/security-audit.json \
|
||||
node skills/openclaw-audit-watchdog/scripts/load_suppression_config.mjs --enable-suppressions
|
||||
```
|
||||
Expected: path resolves correctly (or clear file-not-found error at expanded location).
|
||||
|
||||
## Windows Verification (PowerShell)
|
||||
|
||||
1. Run Node tests:
|
||||
```powershell
|
||||
node skills/clawsec-suite/test/path_resolution.test.mjs
|
||||
node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
node skills/clawsec-suite/test/advisory_suppression.test.mjs
|
||||
```
|
||||
Expected: all pass.
|
||||
|
||||
2. Verify PowerShell env path expansion behavior:
|
||||
```powershell
|
||||
$env:CLAWSEC_LOCAL_FEED = '$env:USERPROFILE\advisories\feed.json'
|
||||
node skills/clawsec-suite/scripts/guarded_skill_install.mjs --skill test-skill --dry-run
|
||||
```
|
||||
Expected: path token is expanded/normalized or fails with a clear error if target files are missing.
|
||||
|
||||
3. Verify escaped literal token rejection:
|
||||
```powershell
|
||||
$env:CLAWSEC_LOCAL_FEED = '\$HOME\advisories\feed.json'
|
||||
node skills/clawsec-suite/scripts/guarded_skill_install.mjs --skill test-skill --dry-run
|
||||
```
|
||||
Expected: `Unexpanded home token` error; no directory creation with literal `$HOME`.
|
||||
|
||||
## Line Endings Sanity
|
||||
|
||||
1. Confirm LF policy is present:
|
||||
```bash
|
||||
test -f .gitattributes && grep -n "eol=lf" .gitattributes
|
||||
```
|
||||
Expected: script/config file patterns enforce LF.
|
||||
|
||||
2. After a CRLF-prone checkout, verify scripts still parse:
|
||||
```bash
|
||||
bash -n scripts/populate-local-feed.sh
|
||||
bash -n scripts/populate-local-skills.sh
|
||||
```
|
||||
Expected: no `^M` shebang/parse errors.
|
||||
|
||||
## Explicit Bug Check: No Literal `$HOME` Directory Creation
|
||||
|
||||
1. Configure a path with a literal/escaped token.
|
||||
2. Run setup/install command.
|
||||
3. Verify command fails early with token error.
|
||||
4. Confirm no `$HOME` segment directory was created under working directories.
|
||||
|
||||
Expected outcome: **no directories containing literal `$HOME` are created by supported setup scripts.**
|
||||
@@ -0,0 +1,73 @@
|
||||
# Cross-Platform Remediation Plan
|
||||
|
||||
## Phase 1: Immediate Risk Closure (Completed)
|
||||
|
||||
### Milestones
|
||||
- Implement explicit home-path expansion + suspicious token rejection in high-risk runtime/install paths.
|
||||
- Add regression tests for path expansion and escaped-token rejection.
|
||||
- Add `.gitattributes` LF policy.
|
||||
- Expand Node lint/type/build CI coverage to Linux/macOS/Windows.
|
||||
- Update install docs with shell-specific guidance and literal `$HOME` troubleshooting.
|
||||
|
||||
### Outcomes
|
||||
- Literal `$HOME` path propagation bug addressed at source.
|
||||
- Core advisory/install path config now fails fast on invalid path tokens.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Windows Parity for Critical Workflows (Next)
|
||||
|
||||
### Quick wins
|
||||
- Add PowerShell equivalents for the most-used manual install/check commands in:
|
||||
- `skills/clawsec-suite/SKILL.md`
|
||||
- `skills/openclaw-audit-watchdog/SKILL.md`
|
||||
- `README.md`
|
||||
- Add a lightweight `scripts/preflight.mjs` to detect missing tools and print OS-specific install hints.
|
||||
|
||||
### Milestones
|
||||
- Native PowerShell instructions for suite setup and advisory hook.
|
||||
- WSL/Git Bash fallback documented where shell scripts are unavoidable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Reduce POSIX Shell Surface (Deeper Refactor)
|
||||
|
||||
### Refactor targets
|
||||
- `scripts/populate-local-feed.sh`
|
||||
- `scripts/populate-local-skills.sh`
|
||||
- `scripts/release-skill.sh`
|
||||
|
||||
### Approach
|
||||
- Re-implement critical paths in Node/Python to remove dependency on `jq/sed/awk/find/chmod` pipelines.
|
||||
- Preserve shell wrappers for backward compatibility; route to new cross-platform implementations.
|
||||
|
||||
### Migration notes
|
||||
- Keep old script entrypoints as wrappers for at least one minor release.
|
||||
- Emit deprecation warnings with exact migration commands.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: CI Hardening and Ongoing Verification
|
||||
|
||||
### Milestones
|
||||
- Keep Node matrix (Linux/macOS/Windows) as required check.
|
||||
- Add targeted Windows smoke tests for install path handling.
|
||||
- Add macOS check for OpenSSL command compatibility notes where relevant.
|
||||
|
||||
### Test strategy
|
||||
- Local:
|
||||
- Run Node test suites that cover path expansion/suppression/install behavior.
|
||||
- Run syntax checks for modified scripts.
|
||||
- CI:
|
||||
- Matrix Node checks + guarded installer/suppression/path tests.
|
||||
- Linux-only security scans remain, but explicitly marked as Linux-scoped.
|
||||
|
||||
---
|
||||
|
||||
## Rollout / Release Considerations
|
||||
|
||||
- No breaking interface changes introduced in this patch set; behavior is stricter only for invalid/unexpanded path tokens.
|
||||
- Communicate in release notes:
|
||||
- path token validation now enforced
|
||||
- how to correct invalid quoted env values
|
||||
- where PowerShell examples live
|
||||
@@ -0,0 +1,215 @@
|
||||
# ClawSec Signing Operations Runbook
|
||||
|
||||
## 1) Purpose
|
||||
|
||||
This runbook defines operational procedures for introducing and running cryptographic signing in the ClawSec repository.
|
||||
|
||||
It covers:
|
||||
- key generation
|
||||
- GitHub secret management
|
||||
- signing workflow integration
|
||||
- key rotation and revocation
|
||||
- incident response
|
||||
|
||||
## 2) Current branch reality (important)
|
||||
|
||||
As of branch `integration/signing-work`, advisory distribution is **unsigned**:
|
||||
|
||||
- Feed writers:
|
||||
- `.github/workflows/poll-nvd-cves.yml` writes `advisories/feed.json` and `skills/clawsec-feed/advisories/feed.json`
|
||||
- `.github/workflows/community-advisory.yml` writes the same files
|
||||
- Feed publish path:
|
||||
- `.github/workflows/deploy-pages.yml` copies to `public/advisories/feed.json`
|
||||
- also mirrors to `public/releases/latest/download/advisories/feed.json`
|
||||
- Feed consumers:
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||
- both default to `https://clawsec.prompt.security/advisories/feed.json`
|
||||
|
||||
This document defines the **target operating model** for signed artifacts while preserving compatibility during migration.
|
||||
|
||||
## 3) Target signed artifacts
|
||||
|
||||
### Advisory feed channel
|
||||
- `advisories/feed.json` (payload)
|
||||
- `advisories/feed.json.sig` (detached Ed25519 signature; base64)
|
||||
- `advisories/feed-signing-public.pem` (pinned public key)
|
||||
|
||||
### Release artifact channel (recommended)
|
||||
- `<release>/checksums.json`
|
||||
- `<release>/checksums.sig`
|
||||
- `advisories/release-signing-public.pem` (or equivalent repo-pinned location)
|
||||
|
||||
## 4) Key roles and custody
|
||||
|
||||
- **Security owner**: approves key lifecycle changes and incident actions.
|
||||
- **Platform owner**: maintains workflows and GitHub secrets.
|
||||
- **Reviewer**: validates fingerprints in PRs/releases.
|
||||
|
||||
Policy:
|
||||
- private keys are never committed
|
||||
- public keys are committed and code-reviewed
|
||||
- key generation occurs on trusted operator workstation or HSM-backed environment
|
||||
|
||||
## 5) Key generation (Ed25519)
|
||||
|
||||
> Run from a secure workstation. Do not run on shared CI runners.
|
||||
|
||||
```bash
|
||||
# Feed signing keypair
|
||||
openssl genpkey -algorithm Ed25519 -out feed-signing-private.pem
|
||||
openssl pkey -in feed-signing-private.pem -pubout -out feed-signing-public.pem
|
||||
|
||||
# Release checksums signing keypair (optional separate key)
|
||||
openssl genpkey -algorithm Ed25519 -out release-signing-private.pem
|
||||
openssl pkey -in release-signing-private.pem -pubout -out release-signing-public.pem
|
||||
```
|
||||
|
||||
Generate fingerprints (store in ticket/change record):
|
||||
|
||||
```bash
|
||||
openssl pkey -pubin -in feed-signing-public.pem -outform DER | shasum -a 256
|
||||
openssl pkey -pubin -in release-signing-public.pem -outform DER | shasum -a 256
|
||||
```
|
||||
|
||||
Optional test-sign before publishing:
|
||||
|
||||
```bash
|
||||
echo '{"probe":"ok"}' > /tmp/probe.json
|
||||
openssl pkeyutl -sign -rawin -inkey feed-signing-private.pem -in /tmp/probe.json -out /tmp/probe.sig.bin
|
||||
openssl base64 -A -in /tmp/probe.sig.bin -out /tmp/probe.sig
|
||||
openssl base64 -d -A -in /tmp/probe.sig -out /tmp/probe.sig.bin
|
||||
openssl pkeyutl -verify -rawin -pubin -inkey feed-signing-public.pem -in /tmp/probe.json -sigfile /tmp/probe.sig.bin
|
||||
```
|
||||
|
||||
## 6) GitHub secrets setup
|
||||
|
||||
### Required secrets
|
||||
|
||||
- `CLAWSEC_SIGNING_PRIVATE_KEY` — PEM-encoded Ed25519 private key (used for both feed and release signing)
|
||||
- `CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE` — (optional) passphrase if the private key is encrypted
|
||||
|
||||
### Procedure
|
||||
|
||||
1. Go to **Repo Settings → Secrets and variables → Actions → New repository secret**.
|
||||
2. Paste full PEM including header/footer.
|
||||
3. Prefer GitHub **Environment secrets** (with required reviewers) for workflow scoping when possible.
|
||||
4. Record change ticket with:
|
||||
- secret name
|
||||
- creator
|
||||
- creation time
|
||||
- key fingerprint
|
||||
|
||||
### Recommended environment protections
|
||||
|
||||
- Require manual approval for workflows that can use signing secrets.
|
||||
- Restrict who can edit protected workflows.
|
||||
- Enable branch protection for `main` and require review for workflow changes.
|
||||
|
||||
## 7) Workflow integration points
|
||||
|
||||
This repo already has feed mutation and deployment workflows. Signing should be inserted as a post-mutation, pre-publish control.
|
||||
|
||||
### Feed pipeline
|
||||
|
||||
Current feed mutation points:
|
||||
- `.github/workflows/poll-nvd-cves.yml`
|
||||
- `.github/workflows/community-advisory.yml`
|
||||
|
||||
Target addition:
|
||||
- add signing step/workflow that:
|
||||
1. regenerates deterministic feed checksums manifest (optional but recommended)
|
||||
2. signs `advisories/feed.json` into `advisories/feed.json.sig`
|
||||
3. verifies signature in CI before commit/publish
|
||||
|
||||
### Pages pipeline
|
||||
|
||||
Current publisher:
|
||||
- `.github/workflows/deploy-pages.yml`
|
||||
|
||||
Target update:
|
||||
- copy `.sig` files to `public/advisories/` and `public/releases/latest/download/advisories/`
|
||||
- fail deploy if expected signed companions are missing after migration enforcement date
|
||||
|
||||
### Skill release pipeline (recommended hardening)
|
||||
|
||||
Current release generator:
|
||||
- `.github/workflows/skill-release.yml` creates `checksums.json`
|
||||
|
||||
Target update:
|
||||
- sign `checksums.json` before `softprops/action-gh-release`
|
||||
- attach `checksums.sig` to each release
|
||||
|
||||
## 8) Rotation policy and runbook
|
||||
|
||||
### Rotation cadence
|
||||
- Routine: every 90 days (or stricter org policy).
|
||||
- Immediate: on suspected exposure, unauthorized workflow change, or unexplained signature mismatch.
|
||||
|
||||
### Routine rotation steps
|
||||
|
||||
1. Generate new keypair(s).
|
||||
2. Open PR that updates public key file(s) and fingerprints documentation.
|
||||
3. Add new private key(s) as GitHub secret(s).
|
||||
4. Merge workflow changes that use new key(s).
|
||||
5. Re-sign latest feed/release manifests.
|
||||
6. Validate verification in CI and in one external client.
|
||||
7. Remove old private key secret(s).
|
||||
8. Keep old public key reference only as long as required for historical verification.
|
||||
|
||||
### Revocation steps
|
||||
|
||||
1. Disable workflows using compromised key.
|
||||
2. Remove compromised GitHub secret(s).
|
||||
3. Commit revocation note and new public key.
|
||||
4. Re-sign latest artifacts with replacement key.
|
||||
5. Publish incident advisory with timestamp and impacted window.
|
||||
|
||||
## 9) Incident response playbook (signing-specific)
|
||||
|
||||
### Triggers
|
||||
- signature verification fails for newly published feed/release
|
||||
- unknown commits/workflow edits touching signing paths
|
||||
- leaked key material, accidental logging, or suspicious secret access
|
||||
|
||||
### Severity guide
|
||||
- **SEV-1**: key exfiltration confirmed or maliciously signed payload published
|
||||
- **SEV-2**: verification failures with unknown cause
|
||||
- **SEV-3**: procedural non-compliance, no active compromise
|
||||
|
||||
### Response phases
|
||||
|
||||
1. **Containment**
|
||||
- pause signing/publish workflows
|
||||
- block further feed merges if authenticity is uncertain
|
||||
2. **Investigation**
|
||||
- review workflow run logs
|
||||
- review commits affecting `.github/workflows/`, `advisories/`, and key files
|
||||
- determine first-bad timestamp and affected artifacts
|
||||
3. **Eradication**
|
||||
- rotate/revoke compromised key(s)
|
||||
- restore trusted artifacts from known-good commit
|
||||
4. **Recovery**
|
||||
- re-sign artifacts
|
||||
- redeploy pages/releases
|
||||
- verify via independent client check
|
||||
5. **Post-incident**
|
||||
- publish timeline and remediation summary
|
||||
- tighten controls (review gates, protected environments, secret scope)
|
||||
|
||||
## 10) Audit evidence checklist
|
||||
|
||||
For each release cycle or feed-signing run, retain:
|
||||
- workflow run URL and commit SHA
|
||||
- signer key fingerprint in use
|
||||
- verification result logs
|
||||
- operator/reviewer approvals
|
||||
- any exception or bypass rationale
|
||||
|
||||
## 11) Minimum acceptance criteria before enforcement
|
||||
|
||||
Before requiring signatures in all clients:
|
||||
- signed artifacts are produced consistently for at least 2 weeks
|
||||
- deploy pipeline mirrors signature companions
|
||||
- one rollback drill and one key rotation drill completed successfully
|
||||
- incident response on-call owner identified and documented
|
||||
+10
-2
@@ -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: {
|
||||
|
||||
+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>
|
||||
|
||||
Generated
+687
-325
File diff suppressed because it is too large
Load Diff
+14
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ClawSec",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.563.0",
|
||||
"lucide-react": "^0.564.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -18,15 +18,21 @@
|
||||
"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/js": "~9.28.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.55.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
"ajv": "6.14.0",
|
||||
"balanced-match": "4.0.3",
|
||||
"brace-expansion": "5.0.2",
|
||||
"minimatch": "10.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
+2
-2
@@ -102,7 +102,7 @@ export const FeedSetup: React.FC = () => {
|
||||
</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} />
|
||||
))}
|
||||
@@ -221,4 +221,4 @@ export const FeedSetup: React.FC = () => {
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
+114
-104
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User, Bot, Copy, Check } 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'];
|
||||
|
||||
@@ -11,7 +10,7 @@ export const Home: React.FC = () => {
|
||||
const [copiedHuman, setCopiedHuman] = useState(false);
|
||||
const [currentFileIndex, setCurrentFileIndex] = 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 +20,7 @@ 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`;
|
||||
const humanInstruction = `Please install clawsec-suite from clawhubnpx clawhub@latest install clawsec-suite`;
|
||||
|
||||
const handleCopyCurl = () => {
|
||||
navigator.clipboard.writeText(curlCommand);
|
||||
@@ -43,9 +42,9 @@ 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
|
||||
Secure your <span className="text-clawd-accent">OpenClaw</span> agents
|
||||
</h2>
|
||||
<p className="text-lg md:text-xl text-gray-400 leading-relaxed">
|
||||
A complete security skill suite for OpenClaw's family of agents. Protect your{' '}
|
||||
@@ -103,114 +102,125 @@ export const Home: React.FC = () => {
|
||||
background-color: rgb(191 107 42 / 0.15);
|
||||
}
|
||||
}
|
||||
@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>
|
||||
);
|
||||
|
||||
@@ -106,7 +106,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(() => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
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"
|
||||
@@ -141,8 +141,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,13 +168,82 @@ 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
|
||||
);
|
||||
|
||||
[.[] |
|
||||
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],
|
||||
@@ -207,7 +279,20 @@ 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" '{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -160,6 +160,6 @@ After release, confirm:
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See repository for details.
|
||||
GNU AGPL v3.0 or later - See repository for details.
|
||||
|
||||
Built by the [Prompt Security](https://prompt.security) team.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"description": "Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security",
|
||||
"keywords": ["release", "versioning", "deployment", "automation", "ci-cd", "skills"],
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# ClawSec ClawHub Checker
|
||||
|
||||
A ClawSec suite skill that enhances the guarded skill installer with ClawHub reputation checks and VirusTotal Code Insight integration.
|
||||
|
||||
## Purpose
|
||||
|
||||
Adds a second layer of security to skill installation by:
|
||||
1. Checking ClawHub's VirusTotal Code Insight reputation scores
|
||||
2. Analyzing skill age, author reputation, and download statistics
|
||||
3. Requiring double confirmation for suspicious skills
|
||||
4. Integrating with existing ClawSec advisory checks
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
clawsec-suite (base)
|
||||
└── clawsec-clawhub-checker (enhancement)
|
||||
├── enhanced_guarded_install.mjs - Main enhanced installer
|
||||
├── check_clawhub_reputation.mjs - Reputation checking logic
|
||||
├── setup_reputation_hook.mjs - Integration script
|
||||
└── hooks/ - Enhanced advisory guardian hook
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# First install the base suite
|
||||
npx clawhub install clawsec-suite
|
||||
|
||||
# Then install the checker
|
||||
npx clawhub install clawsec-clawhub-checker
|
||||
|
||||
# Run setup to integrate with existing suite
|
||||
node scripts/setup_reputation_hook.mjs
|
||||
|
||||
# Restart OpenClaw gateway
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Setup installs these scripts into `clawsec-suite/scripts`:
|
||||
- `enhanced_guarded_install.mjs`
|
||||
- `guarded_skill_install_wrapper.mjs` (drop-in wrapper)
|
||||
- `check_clawhub_reputation.mjs`
|
||||
|
||||
The original `guarded_skill_install.mjs` remains unchanged.
|
||||
|
||||
## Usage
|
||||
|
||||
### Enhanced Guarded Installer
|
||||
|
||||
```bash
|
||||
# Basic usage via wrapper (includes reputation checks)
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --version 1.0.0
|
||||
|
||||
# Direct usage (enhanced script)
|
||||
node scripts/enhanced_guarded_install.mjs --skill some-skill --version 1.0.0
|
||||
|
||||
# With reputation confirmation override
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation
|
||||
|
||||
# Adjust reputation threshold (default: 70)
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --reputation-threshold 80
|
||||
```
|
||||
|
||||
### Reputation Check Only
|
||||
|
||||
```bash
|
||||
# Check reputation without installation
|
||||
node scripts/check_clawhub_reputation.mjs some-skill 1.0.0 70
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0` - Safe to install
|
||||
- `42` - Advisory match found (requires `--confirm-advisory`)
|
||||
- `43` - Reputation warning (requires `--confirm-reputation`) - **NEW**
|
||||
- `1` - Error
|
||||
|
||||
## Reputation Signals Checked
|
||||
|
||||
1. **VirusTotal Code Insight** - Malicious code patterns
|
||||
2. **Skill Age** - New skills (<7 days) are riskier
|
||||
3. **Author Reputation** - Number of published skills
|
||||
4. **Update Frequency** - Stale skills (>90 days)
|
||||
5. **Download Statistics** - Low download counts
|
||||
6. **Version Existence** - Specified version availability
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
- `CLAWHUB_REPUTATION_THRESHOLD` - Minimum score (0-100, default: 70)
|
||||
|
||||
## Integration Points
|
||||
|
||||
1. **Enhanced `guarded_skill_install.mjs`** - Wraps original with reputation checks
|
||||
via `guarded_skill_install_wrapper.mjs` and `enhanced_guarded_install.mjs`
|
||||
2. **Updated advisory guardian hook** - Adds reputation warnings to alerts
|
||||
3. **Catalog entry in clawsec-suite** - Listed as available enhancement
|
||||
|
||||
## Development
|
||||
|
||||
### Files
|
||||
|
||||
- `SKILL.md` - Main documentation
|
||||
- `skill.json` - Skill metadata and SBOM
|
||||
- `scripts/enhanced_guarded_install.mjs` - Enhanced installer
|
||||
- `scripts/check_clawhub_reputation.mjs` - Reputation logic
|
||||
- `scripts/setup_reputation_hook.mjs` - Integration script
|
||||
- `hooks/clawsec-advisory-guardian/lib/reputation.mjs` - Hook module
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Test reputation check
|
||||
node scripts/check_clawhub_reputation.mjs clawsec-suite
|
||||
|
||||
# Test enhanced installer (dry run)
|
||||
node scripts/enhanced_guarded_install.mjs --skill test-skill --dry-run
|
||||
|
||||
# Test setup
|
||||
node scripts/setup_reputation_hook.mjs
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Reputation checks are **heuristic**, not definitive
|
||||
- **False positives** possible with legitimate novel skills
|
||||
- Always **review skill code** before overriding warnings
|
||||
- This is **defense-in-depth**, not replacement for advisory feeds
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3.0 or later - Part of the ClawSec security suite
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: clawsec-clawhub-checker
|
||||
version: 0.0.1
|
||||
description: ClawHub reputation checker for ClawSec suite. Enhances guarded skill installer with VirusTotal Code Insight reputation scores and additional safety checks.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "🛡️"
|
||||
requires:
|
||||
bins: [clawhub, curl, jq]
|
||||
depends_on: [clawsec-suite]
|
||||
---
|
||||
|
||||
# ClawSec ClawHub Checker
|
||||
|
||||
Enhances the ClawSec suite's guarded skill installer with ClawHub reputation checks. Adds a second layer of security by checking VirusTotal Code Insight scores and other reputation signals before allowing skill installation.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. **Wraps `clawhub install`** - Intercepts skill installation requests
|
||||
2. **Checks VirusTotal reputation** - Uses ClawHub's built-in VirusTotal Code Insight
|
||||
3. **Adds double confirmation** - For suspicious skills (reputation score below threshold)
|
||||
4. **Integrates with advisory feed** - Works alongside existing clawsec-suite advisories
|
||||
5. **Provides detailed reports** - Shows why a skill is flagged as suspicious
|
||||
|
||||
## Installation
|
||||
|
||||
This skill must be installed **after** `clawsec-suite`:
|
||||
|
||||
```bash
|
||||
# First install the suite
|
||||
npx clawhub@latest install clawsec-suite
|
||||
|
||||
# Then install the checker
|
||||
npx clawhub@latest install clawsec-clawhub-checker
|
||||
|
||||
# Run the setup script to integrate with clawsec-suite
|
||||
node ~/.openclaw/skills/clawsec-clawhub-checker/scripts/setup_reputation_hook.mjs
|
||||
|
||||
# Restart OpenClaw gateway for changes to take effect
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
After setup, the checker adds `enhanced_guarded_install.mjs` and
|
||||
`guarded_skill_install_wrapper.mjs` under `clawsec-suite/scripts` and updates the advisory
|
||||
guardian hook. The original `guarded_skill_install.mjs` is not replaced.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Enhanced Guarded Installer
|
||||
|
||||
After setup, run the wrapper (drop-in path) or the enhanced script directly:
|
||||
```bash
|
||||
# Recommended drop-in wrapper
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill some-skill --version 1.0.0
|
||||
|
||||
# Or call the enhanced script directly
|
||||
node scripts/enhanced_guarded_install.mjs --skill some-skill --version 1.0.0
|
||||
```
|
||||
|
||||
The enhanced flow:
|
||||
1. **Advisory check** (existing) - Checks clawsec advisory feed
|
||||
2. **Reputation check** (new) - Queries ClawHub for VirusTotal scores
|
||||
3. **Risk assessment** - Combines advisory + reputation signals
|
||||
4. **Double confirmation** - If risky, requires explicit `--confirm-reputation`
|
||||
|
||||
### Reputation Signals Checked
|
||||
|
||||
1. **VirusTotal Code Insight** - Malicious code patterns, external dependencies (Docker usage, network calls, eval usage, crypto keys)
|
||||
2. **Skill age & updates** - New skills vs established ones
|
||||
3. **Author reputation** - Other skills by same author
|
||||
4. **Download statistics** - Popularity signals
|
||||
|
||||
### Exit Codes
|
||||
|
||||
- `0` - Safe to install (no advisories, good reputation)
|
||||
- `42` - Advisory match found (existing behavior)
|
||||
- `43` - Reputation warning (new - requires `--confirm-reputation`)
|
||||
- `1` - Error
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
- `CLAWHUB_REPUTATION_THRESHOLD` - Minimum reputation score (0-100, default: 70)
|
||||
|
||||
## Integration with Existing Suite
|
||||
|
||||
The checker enhances but doesn't replace existing security:
|
||||
- **Advisory feed still primary** - Known malicious skills blocked first
|
||||
- **Reputation is secondary** - Unknown/suspicious skills get extra scrutiny
|
||||
- **Double confirmation preserved** - Both layers require explicit user approval
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Try to install a skill
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0
|
||||
|
||||
# Output might show:
|
||||
# WARNING: Skill "suspicious-skill" has low reputation score (45/100)
|
||||
# - Flagged by VirusTotal Code Insight: crypto keys, external APIs, eval usage
|
||||
# - Author has no other published skills
|
||||
# - Skill is less than 7 days old
|
||||
#
|
||||
# To install despite reputation warning, run:
|
||||
# node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation
|
||||
|
||||
# Install with confirmation
|
||||
node scripts/guarded_skill_install_wrapper.mjs --skill suspicious-skill --version 1.0.0 --confirm-reputation
|
||||
```
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- This is a **defense-in-depth** layer, not a replacement for advisory feeds
|
||||
- VirusTotal scores are **heuristic**, not definitive
|
||||
- **False positives possible** - Legitimate skills with novel patterns might be flagged
|
||||
- Always **review skill code** before installing with `--confirm-reputation`
|
||||
|
||||
## Current Limitations
|
||||
|
||||
### Missing OpenClaw Internal Check Data
|
||||
ClawHub shows two security badges on skill pages:
|
||||
1. **VirusTotal Code Insight** - ✅ Our checker catches these flags
|
||||
2. **OpenClaw internal check** - ❌ Not exposed via API (only on website)
|
||||
|
||||
Example from `clawsec-suite` page:
|
||||
- VirusTotal: "Benign" ✓
|
||||
- OpenClaw internal check: "The package is internally consistent with a feed-monitoring / advisory-guardian purpose, but a few operational details and optional bypasses deserve attention before installing."
|
||||
|
||||
**Our checker cannot access OpenClaw internal check warnings** as they're not exposed via `clawhub` CLI or API.
|
||||
|
||||
### Recommendation for ClawHub
|
||||
To enable complete reputation checking, ClawHub should expose internal check results via:
|
||||
- `clawhub inspect --json` endpoint
|
||||
- Additional API field for security tools
|
||||
- Or include in `clawhub install` warning output
|
||||
|
||||
### Workaround
|
||||
Our heuristic checks (skill age, author reputation, downloads, updates) provide similar risk assessment but miss specific operational warnings about bypasses, missing signatures, etc. Always check the ClawHub website for complete security assessment.
|
||||
|
||||
## Development
|
||||
|
||||
To modify the reputation checking logic, edit:
|
||||
- `scripts/enhanced_guarded_install.mjs` - Main enhanced installer
|
||||
- `scripts/check_clawhub_reputation.mjs` - Reputation checking logic
|
||||
- `hooks/clawsec-advisory-guardian/lib/reputation.mjs` - Hook integration
|
||||
|
||||
## License
|
||||
|
||||
GNU AGPL v3.0 or later - Part of the ClawSec security suite
|
||||
@@ -0,0 +1,99 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Check reputation for a skill
|
||||
* @param {string} skillName - Skill name
|
||||
* @param {string} version - Skill version
|
||||
* @returns {Promise<{safe: boolean, score: number, warnings: string[]}>}
|
||||
*/
|
||||
export async function checkReputation(skillName, version) {
|
||||
const result = {
|
||||
safe: true,
|
||||
score: 100,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Try to get skill slug from directory name or skill.json
|
||||
// For now, use skillName as slug (simplified)
|
||||
const skillSlug = skillName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Run the reputation check script
|
||||
// Current file is at: .../hooks/clawsec-advisory-guardian/lib/reputation.mjs
|
||||
// We need to go up 3 levels to get to the skill root directory
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const checkerDir = path.resolve(__dirname, '../../..');
|
||||
|
||||
const reputationCheck = spawnSync(
|
||||
"node",
|
||||
[
|
||||
`${checkerDir}/scripts/check_clawhub_reputation.mjs`,
|
||||
skillSlug,
|
||||
version || "",
|
||||
"70" // Default threshold
|
||||
],
|
||||
{ encoding: "utf-8", cwd: checkerDir }
|
||||
);
|
||||
|
||||
if (reputationCheck.status === 0) {
|
||||
try {
|
||||
const repResult = JSON.parse(reputationCheck.stdout);
|
||||
result.safe = repResult.safe;
|
||||
result.score = repResult.score;
|
||||
result.warnings = repResult.warnings;
|
||||
} catch (parseError) {
|
||||
result.warnings.push(`Failed to parse reputation result: ${parseError.message}`);
|
||||
result.score = 60;
|
||||
result.safe = result.score >= 70;
|
||||
}
|
||||
} else if (reputationCheck.status === 43) {
|
||||
// Reputation warning exit code
|
||||
try {
|
||||
const repResult = JSON.parse(reputationCheck.stdout);
|
||||
result.safe = false;
|
||||
result.score = repResult.score;
|
||||
result.warnings = repResult.warnings;
|
||||
} catch {
|
||||
result.safe = false;
|
||||
result.score = 50;
|
||||
result.warnings.push("Skill flagged by reputation check");
|
||||
}
|
||||
} else {
|
||||
// Error running check
|
||||
result.warnings.push(`Reputation check failed: ${reputationCheck.stderr || 'Unknown error'}`);
|
||||
result.score = 60;
|
||||
result.safe = result.score >= 70;
|
||||
}
|
||||
} catch (error) {
|
||||
result.warnings.push(`Reputation check error: ${error.message}`);
|
||||
result.score = 50;
|
||||
result.safe = result.score >= 70;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reputation warning for alert messages
|
||||
* @param {{score: number, warnings: string[]}} reputationInfo
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatReputationWarning(reputationInfo) {
|
||||
if (!reputationInfo || reputationInfo.score >= 70) return "";
|
||||
|
||||
const lines = [
|
||||
`\n⚠️ **REPUTATION WARNING** (Score: ${reputationInfo.score}/100)`,
|
||||
];
|
||||
|
||||
if (reputationInfo.warnings.length > 0) {
|
||||
lines.push("");
|
||||
reputationInfo.warnings.forEach(w => lines.push(`• ${w}`));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("This skill has low reputation score. Review carefully before installation.");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Check ClawHub reputation for a skill
|
||||
* @param {string} skillSlug - Skill slug to check
|
||||
* @param {string} version - Optional version
|
||||
* @param {number} threshold - Minimum reputation score (0-100)
|
||||
* @returns {Promise<{safe: boolean, score: number, warnings: string[], virustotal: string[]}>}
|
||||
*/
|
||||
export async function checkClawhubReputation(skillSlug, version, threshold = 70) {
|
||||
const result = {
|
||||
safe: true,
|
||||
score: 100, // Default score if no checks fail
|
||||
warnings: [],
|
||||
virustotal: [],
|
||||
};
|
||||
|
||||
// Input validation — reject anything that isn't a safe slug or semver
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(skillSlug)) {
|
||||
result.warnings.push(`Invalid skill slug: ${skillSlug}`);
|
||||
result.score = 0;
|
||||
result.safe = false;
|
||||
return result;
|
||||
}
|
||||
// Semver validation: supports major.minor.patch with optional pre-release and build metadata
|
||||
// Examples: 1.0.0, 1.0.0-alpha.1, 1.0.0-beta+20130313144700
|
||||
// More restrictive than full semver spec for security (prevents command injection)
|
||||
if (version && !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(version)) {
|
||||
result.warnings.push(`Invalid version format: ${version}`);
|
||||
result.score = 0;
|
||||
result.safe = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check 1: Try to inspect the skill via clawhub
|
||||
const inspectResult = spawnSync(
|
||||
"clawhub",
|
||||
["inspect", skillSlug, "--json"],
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
|
||||
if (inspectResult.status !== 0) {
|
||||
// Skill doesn't exist or can't be inspected
|
||||
result.warnings.push(`Skill "${skillSlug}" not found or cannot be inspected`);
|
||||
result.score = Math.min(result.score, 50);
|
||||
} else {
|
||||
try {
|
||||
const skillInfo = JSON.parse(inspectResult.stdout);
|
||||
|
||||
// Check 2: Skill age (new skills are riskier)
|
||||
if (skillInfo.skill?.createdAt) {
|
||||
const createdMs = skillInfo.skill.createdAt;
|
||||
const ageDays = (Date.now() - createdMs) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (ageDays < 7) {
|
||||
result.warnings.push(`Skill is less than 7 days old (${ageDays.toFixed(1)} days)`);
|
||||
result.score -= 15;
|
||||
} else if (ageDays < 30) {
|
||||
result.warnings.push(`Skill is less than 30 days old (${ageDays.toFixed(1)} days)`);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: Update frequency (stale skills are riskier)
|
||||
if (skillInfo.skill?.updatedAt && skillInfo.skill?.createdAt) {
|
||||
const updatedMs = skillInfo.skill.updatedAt;
|
||||
const createdMs = skillInfo.skill.createdAt;
|
||||
const updateAgeDays = (Date.now() - updatedMs) / (1000 * 60 * 60 * 24);
|
||||
const totalAgeDays = (Date.now() - createdMs) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (updateAgeDays > 90 && totalAgeDays > 90) {
|
||||
result.warnings.push(`Skill hasn't been updated in ${updateAgeDays.toFixed(0)} days`);
|
||||
result.score -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Check 4: Author reputation
|
||||
if (skillInfo.owner?.handle) {
|
||||
const authorResult = spawnSync(
|
||||
"clawhub",
|
||||
["search", skillInfo.owner.handle],
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
|
||||
if (authorResult.status === 0) {
|
||||
const lines = authorResult.stdout.trim().split('\n').filter(l => l);
|
||||
const skillCount = lines.length - 1; // First line is header
|
||||
|
||||
if (skillCount === 1) {
|
||||
result.warnings.push(`Author "${skillInfo.owner.handle}" has only 1 published skill`);
|
||||
result.score -= 10;
|
||||
} else if (skillCount < 3) {
|
||||
result.warnings.push(`Author "${skillInfo.owner.handle}" has only ${skillCount} published skills`);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 5: Download statistics
|
||||
if (skillInfo.skill?.stats?.downloads !== undefined) {
|
||||
const downloads = skillInfo.skill.stats.downloads;
|
||||
if (downloads < 10) {
|
||||
result.warnings.push(`Low download count: ${downloads}`);
|
||||
result.score -= 10;
|
||||
} else if (downloads < 100) {
|
||||
result.warnings.push(`Moderate download count: ${downloads}`);
|
||||
result.score -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
result.warnings.push(`Failed to parse skill information: ${parseError.message}`);
|
||||
result.score = Math.min(result.score, 60);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 6: Try installation to detect VirusTotal Code Insight warnings
|
||||
// Note: This approach has potential side effects:
|
||||
// - May download/cache skill metadata before declining
|
||||
// - Depends on clawhub's prompting behavior (sending "n\n" to decline)
|
||||
// - If clawhub inspect provided security flags, we'd use that instead
|
||||
// This is the only way to programmatically access VirusTotal warnings currently
|
||||
const installArgs = ["install", skillSlug];
|
||||
if (version) installArgs.push("--version", version);
|
||||
const installCheck = spawnSync("clawhub", installArgs, {
|
||||
input: "n\n", // Automatically decline the installation prompt
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
const output = (installCheck.stdout || "") + (installCheck.stderr || "");
|
||||
if (output.includes("suspicious") || output.includes("VirusTotal") || output.includes("flagged")) {
|
||||
result.virustotal.push("Flagged by ClawHub's VirusTotal Code Insight");
|
||||
result.score -= 40; // More severe penalty for VirusTotal flag
|
||||
|
||||
// Extract specific warnings
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.includes("Warning:") || line.includes("risky patterns") ||
|
||||
line.includes("crypto keys") || line.includes("external APIs") ||
|
||||
line.includes("eval") || line.includes("VirusTotal Code Insight")) {
|
||||
const cleanLine = line.trim().replace(/^⚠️\s*/, '').replace(/^\s*Warning:\s*/, '');
|
||||
if (cleanLine && !result.virustotal.includes(cleanLine)) {
|
||||
result.virustotal.push(cleanLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7: If version specified, check if it exists
|
||||
if (version) {
|
||||
const versionCheck = spawnSync(
|
||||
"clawhub",
|
||||
["inspect", skillSlug, "--version", version, "--json"],
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
|
||||
if (versionCheck.status !== 0) {
|
||||
result.warnings.push(`Version ${version} not found for skill ${skillSlug}`);
|
||||
result.score -= 20;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score is within bounds
|
||||
result.score = Math.max(0, Math.min(100, result.score));
|
||||
result.safe = result.score >= threshold;
|
||||
|
||||
// Add summary warning if below threshold
|
||||
if (!result.safe) {
|
||||
result.warnings.unshift(`Reputation score ${result.score}/100 below threshold ${threshold}/100`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.warnings.push(`Reputation check error: ${error.message}`);
|
||||
result.score = 50;
|
||||
result.safe = result.score >= threshold;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// CLI interface for direct usage
|
||||
const isCliEntrypoint =
|
||||
process.argv[1] !== undefined &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||
|
||||
if (isCliEntrypoint) {
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
console.error("Usage: node check_clawhub_reputation.mjs <skill-slug> [version] [threshold]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const skillSlug = args[0];
|
||||
const version = args[1] || "";
|
||||
let threshold = 70;
|
||||
if (args[2] !== undefined) {
|
||||
const parsedThreshold = parseInt(args[2], 10);
|
||||
if (!Number.isInteger(parsedThreshold) || parsedThreshold < 0 || parsedThreshold > 100) {
|
||||
console.error(
|
||||
`Invalid threshold: "${args[2]}". Threshold must be an integer between 0 and 100.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
threshold = parsedThreshold;
|
||||
}
|
||||
|
||||
const result = await checkClawhubReputation(skillSlug, version, threshold);
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.safe) {
|
||||
process.exit(43);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { checkClawhubReputation } from "./check_clawhub_reputation.mjs";
|
||||
|
||||
const EXIT_ADVISORY_CONFIRM_REQUIRED = 42;
|
||||
const EXIT_REPUTATION_CONFIRM_REQUIRED = 43;
|
||||
|
||||
function printUsage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill <skill-name> [--version <version>] [--confirm-advisory] [--confirm-reputation] [--dry-run] [--reputation-threshold <score>]",
|
||||
"",
|
||||
"Examples:",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill helper-plus --version 1.0.1",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill helper-plus --version 1.0.1 --confirm-advisory --confirm-reputation",
|
||||
" node scripts/enhanced_guarded_install.mjs --skill suspicious-skill --reputation-threshold 80",
|
||||
"",
|
||||
"Exit codes:",
|
||||
" 0 success / no advisory or reputation block",
|
||||
" 42 advisory matched and second confirmation is required",
|
||||
" 43 reputation warning and second confirmation is required",
|
||||
" 1 error",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
// Parse and validate CLAWHUB_REPUTATION_THRESHOLD environment variable
|
||||
let defaultThreshold = 70;
|
||||
const envThreshold = process.env.CLAWHUB_REPUTATION_THRESHOLD;
|
||||
|
||||
if (envThreshold !== undefined && envThreshold !== "") {
|
||||
const parsedEnv = parseInt(envThreshold, 10);
|
||||
if (Number.isNaN(parsedEnv) || parsedEnv < 0 || parsedEnv > 100) {
|
||||
throw new Error(
|
||||
`Invalid CLAWHUB_REPUTATION_THRESHOLD environment variable: "${envThreshold}". Must be between 0 and 100.`
|
||||
);
|
||||
}
|
||||
defaultThreshold = parsedEnv;
|
||||
}
|
||||
|
||||
const parsed = {
|
||||
skill: "",
|
||||
version: "",
|
||||
confirmAdvisory: false,
|
||||
confirmReputation: false,
|
||||
dryRun: false,
|
||||
reputationThreshold: defaultThreshold,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === "--skill") {
|
||||
parsed.skill = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--version") {
|
||||
parsed.version = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm-advisory") {
|
||||
parsed.confirmAdvisory = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm-reputation") {
|
||||
parsed.confirmReputation = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--reputation-threshold") {
|
||||
parsed.reputationThreshold = parseInt(String(argv[i + 1] ?? "70"), 10);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--help" || token === "-h") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
|
||||
if (!parsed.skill) {
|
||||
throw new Error("Missing required argument: --skill");
|
||||
}
|
||||
// Must start with alphanumeric, then can contain hyphens (matches check_clawhub_reputation.mjs validation)
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(parsed.skill)) {
|
||||
throw new Error("Invalid --skill value. Must start with a letter or digit, followed by lowercase letters, digits, and hyphens.");
|
||||
}
|
||||
if (parsed.version && !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/.test(parsed.version)) {
|
||||
throw new Error(
|
||||
"Invalid --version value. Must be semantic version format (e.g., 1.2.3, 1.2.3-beta.1, 1.2.3+build.45)."
|
||||
);
|
||||
}
|
||||
if (parsed.reputationThreshold < 0 || parsed.reputationThreshold > 100 || Number.isNaN(parsed.reputationThreshold)) {
|
||||
throw new Error("Invalid --reputation-threshold value. Must be between 0 and 100.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildOriginalArgs(argv) {
|
||||
// Filter out reputation-specific arguments that the original script doesn't understand
|
||||
const originalArgs = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === "--confirm-reputation" || token === "--reputation-threshold") {
|
||||
// Skip reputation-specific flags
|
||||
if (token === "--reputation-threshold" && i + 1 < argv.length) {
|
||||
// Also skip the value associated with --reputation-threshold
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
originalArgs.push(token);
|
||||
}
|
||||
|
||||
return originalArgs;
|
||||
}
|
||||
|
||||
async function runOriginalGuardedInstall(args) {
|
||||
// Find the original guarded_skill_install.mjs from clawsec-suite
|
||||
const suiteDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const originalScript = path.join(suiteDir, "scripts", "guarded_skill_install.mjs");
|
||||
|
||||
try {
|
||||
await fs.access(originalScript);
|
||||
} catch {
|
||||
throw new Error(`Original guarded_skill_install.mjs not found at ${originalScript}. Is clawsec-suite installed?`);
|
||||
}
|
||||
|
||||
// Pass through environment without modification
|
||||
// The original guarded_skill_install.mjs handles --confirm-advisory properly
|
||||
const child = spawnSync(
|
||||
"node",
|
||||
[originalScript, ...args.originalArgs],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
cwd: suiteDir,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
exitCode: child.status ?? 1,
|
||||
signal: child.signal,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const args = parseArgs(cliArgs);
|
||||
|
||||
// Build args for original script (excluding reputation-specific args)
|
||||
args.originalArgs = buildOriginalArgs(cliArgs);
|
||||
|
||||
// Step 1: Check reputation (unless already confirmed)
|
||||
if (!args.confirmReputation) {
|
||||
console.log(`Checking ClawHub reputation for ${args.skill}${args.version ? `@${args.version}` : ""}...`);
|
||||
|
||||
const reputationResult = await checkClawhubReputation(args.skill, args.version, args.reputationThreshold);
|
||||
|
||||
if (!reputationResult.safe) {
|
||||
console.error("\n" + "=".repeat(80));
|
||||
console.error("REPUTATION WARNING");
|
||||
console.error("=".repeat(80));
|
||||
console.error(`Skill "${args.skill}" has low reputation score: ${reputationResult.score}/100`);
|
||||
console.error(`Threshold: ${args.reputationThreshold}/100`);
|
||||
console.error("");
|
||||
|
||||
if (reputationResult.warnings.length > 0) {
|
||||
console.error("Warnings:");
|
||||
reputationResult.warnings.forEach(w => console.error(` • ${w}`));
|
||||
console.error("");
|
||||
}
|
||||
|
||||
if (reputationResult.virustotal) {
|
||||
console.error("VirusTotal Code Insight flags:");
|
||||
reputationResult.virustotal.forEach(v => console.error(` • ${v}`));
|
||||
console.error("");
|
||||
}
|
||||
|
||||
console.error("To install despite reputation warning, run with --confirm-reputation flag:");
|
||||
console.error(` node ${process.argv[1]} --skill ${args.skill}${args.version ? ` --version ${args.version}` : ""} --confirm-reputation`);
|
||||
console.error("");
|
||||
console.error("=".repeat(80));
|
||||
|
||||
process.exit(EXIT_REPUTATION_CONFIRM_REQUIRED);
|
||||
}
|
||||
|
||||
console.log(`✓ Reputation check passed: ${reputationResult.score}/100`);
|
||||
} else {
|
||||
console.log(`⚠️ Reputation confirmation override enabled for ${args.skill}`);
|
||||
}
|
||||
|
||||
// Step 2: Run original guarded installer (handles advisory checks)
|
||||
console.log("\nRunning advisory checks...");
|
||||
const result = await runOriginalGuardedInstall(args);
|
||||
|
||||
if (result.exitCode !== 0 && result.exitCode !== EXIT_ADVISORY_CONFIRM_REQUIRED) {
|
||||
process.exit(result.exitCode);
|
||||
}
|
||||
|
||||
// If we get here, either success (0) or advisory confirmation required (42)
|
||||
process.exit(result.exitCode);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
async function main() {
|
||||
console.log("Setting up ClawHub reputation checker integration...");
|
||||
|
||||
// Paths
|
||||
const suiteDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const checkerDir = path.join(os.homedir(), ".openclaw", "skills", "clawsec-clawhub-checker");
|
||||
const hookLibDir = path.join(suiteDir, "hooks", "clawsec-advisory-guardian", "lib");
|
||||
const suiteScriptsDir = path.join(suiteDir, "scripts");
|
||||
|
||||
try {
|
||||
// Check if clawsec-suite is installed
|
||||
await fs.access(suiteDir);
|
||||
console.log(`✓ Found clawsec-suite at ${suiteDir}`);
|
||||
|
||||
// Check if hook lib directory exists
|
||||
await fs.access(hookLibDir);
|
||||
console.log(`✓ Found advisory guardian hook at ${hookLibDir}`);
|
||||
|
||||
// Copy reputation module to hook lib
|
||||
const reputationModuleSrc = path.join(checkerDir, "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs");
|
||||
const reputationModuleDst = path.join(hookLibDir, "reputation.mjs");
|
||||
|
||||
await fs.copyFile(reputationModuleSrc, reputationModuleDst);
|
||||
console.log(`✓ Copied reputation module to ${reputationModuleDst}`);
|
||||
|
||||
// Update hook handler to import reputation module
|
||||
const hookHandlerPath = path.join(suiteDir, "hooks", "clawsec-advisory-guardian", "handler.ts");
|
||||
let handlerContent = await fs.readFile(hookHandlerPath, "utf8");
|
||||
|
||||
// WARNING: This setup script uses string manipulation to modify handler.ts
|
||||
// This is fragile and may break if the handler structure changes
|
||||
// Consider using AST-based transformation or manual integration for production use
|
||||
let handlerChanged = false;
|
||||
const importLine = "import { checkReputation } from \"./lib/reputation.mjs\";";
|
||||
const reputationMarker = "// ClawHub reputation check for matched skills";
|
||||
|
||||
if (!handlerContent.includes(importLine)) {
|
||||
// Add import after other imports
|
||||
const importIndex = handlerContent.lastIndexOf("import");
|
||||
if (importIndex === -1) {
|
||||
throw new Error("Could not find import statements in handler.ts. Manual integration required.");
|
||||
}
|
||||
|
||||
const lineEndIndex = handlerContent.indexOf("\n", importIndex);
|
||||
handlerContent = handlerContent.slice(0, lineEndIndex + 1) + `${importLine}\n` + handlerContent.slice(lineEndIndex + 1);
|
||||
handlerChanged = true;
|
||||
} else {
|
||||
console.log("✓ Hook handler already imports reputation module");
|
||||
}
|
||||
|
||||
if (!handlerContent.includes(reputationMarker)) {
|
||||
const findMatchesAnchors = [
|
||||
{ line: "const allMatches = findMatches(feed, installedSkills);", variable: "allMatches" },
|
||||
{ line: "const matches = findMatches(feed, installedSkills);", variable: "matches" },
|
||||
];
|
||||
const matchedAnchor = findMatchesAnchors.find((entry) => handlerContent.includes(entry.line));
|
||||
|
||||
if (!matchedAnchor) {
|
||||
throw new Error(
|
||||
"Could not find findMatches assignment in handler.ts. Refusing partial setup. Manual integration required."
|
||||
);
|
||||
}
|
||||
|
||||
const anchorIndex = handlerContent.indexOf(matchedAnchor.line);
|
||||
const insertIndex = handlerContent.indexOf("\n", anchorIndex) + 1;
|
||||
const reputationCheckCode = `
|
||||
${reputationMarker}
|
||||
for (const match of ${matchedAnchor.variable}) {
|
||||
const repResult = await checkReputation(match.skill.name, match.skill.version);
|
||||
if (!repResult.safe) {
|
||||
match.reputationWarning = true;
|
||||
match.reputationScore = repResult.score;
|
||||
match.reputationWarnings = repResult.warnings;
|
||||
}
|
||||
}
|
||||
`;
|
||||
handlerContent = handlerContent.slice(0, insertIndex) + reputationCheckCode + handlerContent.slice(insertIndex);
|
||||
handlerChanged = true;
|
||||
} else {
|
||||
console.log("✓ Hook handler already has reputation scan block");
|
||||
}
|
||||
|
||||
if (handlerChanged) {
|
||||
await fs.writeFile(hookHandlerPath, handlerContent);
|
||||
console.log("✓ Updated hook handler with reputation checks");
|
||||
} else {
|
||||
console.log("✓ Hook handler already has required reputation integration");
|
||||
}
|
||||
|
||||
// Copy enhanced installer and reputation checker scripts
|
||||
const enhancedInstallerSrc = path.join(checkerDir, "scripts", "enhanced_guarded_install.mjs");
|
||||
const enhancedInstallerDst = path.join(suiteDir, "scripts", "enhanced_guarded_install.mjs");
|
||||
const reputationCheckSrc = path.join(checkerDir, "scripts", "check_clawhub_reputation.mjs");
|
||||
const reputationCheckDst = path.join(suiteScriptsDir, "check_clawhub_reputation.mjs");
|
||||
|
||||
await fs.copyFile(enhancedInstallerSrc, enhancedInstallerDst);
|
||||
console.log(`✓ Installed enhanced guarded installer at ${enhancedInstallerDst}`);
|
||||
|
||||
await fs.copyFile(reputationCheckSrc, reputationCheckDst);
|
||||
console.log(`✓ Installed reputation check script at ${reputationCheckDst}`);
|
||||
|
||||
// Create wrapper script that uses enhanced installer by default
|
||||
const wrapperScript = `#!/usr/bin/env node
|
||||
|
||||
// Wrapper that uses enhanced guarded installer with reputation checks
|
||||
// This replaces the original guarded_skill_install.mjs in usage
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const enhancedScript = path.join(__dirname, "enhanced_guarded_install.mjs");
|
||||
|
||||
const result = spawnSync("node", [enhancedScript, ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
`;
|
||||
|
||||
const wrapperPath = path.join(suiteDir, "scripts", "guarded_skill_install_wrapper.mjs");
|
||||
await fs.writeFile(wrapperPath, wrapperScript);
|
||||
await fs.chmod(wrapperPath, 0o755);
|
||||
console.log(`✓ Created wrapper script at ${wrapperPath}`);
|
||||
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("SETUP COMPLETE");
|
||||
console.log("=".repeat(80));
|
||||
console.log("\nThe ClawHub reputation checker has been integrated with clawsec-suite.");
|
||||
console.log("\nWhat changed:");
|
||||
console.log("1. Enhanced guarded installer with reputation checks installed");
|
||||
console.log("2. Reputation check helper script installed");
|
||||
console.log("3. Advisory guardian hook updated to include reputation warnings");
|
||||
console.log("4. Wrapper script created for backward compatibility");
|
||||
console.log("\nUsage:");
|
||||
console.log(" node scripts/enhanced_guarded_install.mjs --skill <name> [--version <ver>]");
|
||||
console.log(" node scripts/guarded_skill_install_wrapper.mjs --skill <name> [--version <ver>]");
|
||||
console.log("\nNew exit code: 43 = Reputation warning (requires --confirm-reputation)");
|
||||
console.log("\nRestart OpenClaw gateway for hook changes to take effect.");
|
||||
console.log("=".repeat(80));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Setup failed:", error.message);
|
||||
console.error("\nMake sure:");
|
||||
console.error("1. clawsec-suite is installed (npx clawhub install clawsec-suite)");
|
||||
console.error("2. You have write permissions to the suite directory");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "clawsec-clawhub-checker",
|
||||
"version": "0.0.1",
|
||||
"description": "ClawHub reputation checker for ClawSec suite. Enhances guarded skill installer with VirusTotal Code Insight reputation scores and additional safety checks.",
|
||||
"author": "abutbul",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
"keywords": [
|
||||
"security",
|
||||
"reputation",
|
||||
"clawhub",
|
||||
"virustotal",
|
||||
"skills",
|
||||
"installer",
|
||||
"verification",
|
||||
"defense-in-depth",
|
||||
"openclaw"
|
||||
],
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "Skill documentation and usage guide"
|
||||
},
|
||||
{
|
||||
"path": "scripts/enhanced_guarded_install.mjs",
|
||||
"required": true,
|
||||
"description": "Enhanced guarded installer with reputation checks"
|
||||
},
|
||||
{
|
||||
"path": "scripts/check_clawhub_reputation.mjs",
|
||||
"required": true,
|
||||
"description": "ClawHub reputation checking logic"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_reputation_hook.mjs",
|
||||
"required": true,
|
||||
"description": "Setup script to enhance existing advisory guardian hook"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/reputation.mjs",
|
||||
"required": true,
|
||||
"description": "Reputation checking module for advisory guardian hook"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"required": false,
|
||||
"description": "Additional documentation and development guide"
|
||||
},
|
||||
{
|
||||
"path": "test/reputation_check.test.mjs",
|
||||
"required": false,
|
||||
"description": "Test suite for reputation checking functionality"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"clawsec-suite": ">=0.0.10"
|
||||
},
|
||||
"integration": {
|
||||
"clawsec-suite": {
|
||||
"enhances": [
|
||||
"guarded_skill_install.mjs",
|
||||
"clawsec-advisory-guardian hook"
|
||||
],
|
||||
"adds_exit_codes": {
|
||||
"43": "Reputation warning - requires --confirm-reputation"
|
||||
},
|
||||
"adds_arguments": [
|
||||
"--confirm-reputation",
|
||||
"--reputation-threshold"
|
||||
]
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"emoji": "🛡️",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": ["clawhub", "curl", "jq"]
|
||||
},
|
||||
"triggers": [
|
||||
"clawhub reputation",
|
||||
"skill reputation check",
|
||||
"virustotal skill check",
|
||||
"safe skill install",
|
||||
"check skill safety",
|
||||
"skill security score"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Reputation check tests for clawsec-clawhub-checker.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Input validation (command injection prevention)
|
||||
* - Reputation scoring with mocked clawhub output
|
||||
* - formatReputationWarning output formatting
|
||||
* - Enhanced installer argument parsing
|
||||
*
|
||||
* Run: node skills/clawsec-clawhub-checker/test/reputation_check.test.mjs
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CHECKER_SCRIPT = path.resolve(__dirname, "..", "scripts", "check_clawhub_reputation.mjs");
|
||||
const ENHANCED_INSTALL_SCRIPT = path.resolve(__dirname, "..", "scripts", "enhanced_guarded_install.mjs");
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount++;
|
||||
console.log(`\u2713 ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`\u2717 ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function runScript(scriptPath, args, env) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("node", [scriptPath, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid skill slug is rejected (command injection prevention)
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidSlugRejected() {
|
||||
const testName = "reputation_check: invalid slug with shell metacharacters is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test; rm -rf /', '', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false && parsed.warnings.some(w => w.includes("Invalid skill slug"))) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected score 0 with invalid slug warning, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid version format is rejected (command injection prevention)
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidVersionRejected() {
|
||||
const testName = "reputation_check: invalid version with shell metacharacters is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0; curl evil.com', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false && parsed.warnings.some(w => w.includes("Invalid version format"))) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected score 0 with invalid version warning, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Valid slug and version pass input validation
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testValidInputsAccepted() {
|
||||
const testName = "reputation_check: valid slug and semver pass input validation";
|
||||
try {
|
||||
// clawhub is not installed, so the check will fail at the inspect step,
|
||||
// but it should NOT fail at input validation
|
||||
const result = await runScript(CHECKER_SCRIPT, ['my-test-skill', '1.0.0', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Should not contain input validation errors
|
||||
const hasInputError = parsed.warnings.some(
|
||||
w => w.includes("Invalid skill slug") || w.includes("Invalid version format")
|
||||
);
|
||||
if (!hasInputError) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Valid inputs were rejected: ${JSON.stringify(parsed.warnings)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Slug with uppercase or special chars is rejected
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testUppercaseSlugRejected() {
|
||||
const testName = "reputation_check: uppercase slug is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['Test-Skill', '1.0.0', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.score === 0 && parsed.safe === false) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected uppercase slug to be rejected, got: ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Empty slug shows usage error
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEmptySlugShowsUsage() {
|
||||
const testName = "reputation_check: empty slug shows usage error";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, []);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Usage:")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with usage message, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Version with pre-release tag is accepted
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testPreReleaseVersionAccepted() {
|
||||
const testName = "reputation_check: pre-release version format is accepted";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0-beta.1', '70']);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasVersionError = parsed.warnings.some(w => w.includes("Invalid version format"));
|
||||
if (!hasVersionError) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Pre-release version was rejected: ${JSON.stringify(parsed.warnings)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: CLI entrypoint guard works when script path is relative
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testRelativePathCliEntrypointWorks() {
|
||||
const testName = "reputation_check: CLI entrypoint works with relative script path";
|
||||
try {
|
||||
const relativeCheckerScript = path.relative(process.cwd(), CHECKER_SCRIPT);
|
||||
const result = await runScript(relativeCheckerScript, ['bad slug', '', '70']);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
fail(testName, `Could not parse output with relative script path: ${result.stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.code === 43 &&
|
||||
parsed.safe === false &&
|
||||
parsed.warnings.some((w) => w.includes("Invalid skill slug"))
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 43 with invalid slug warning via relative path, got code ${result.code}: ${JSON.stringify(parsed)}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Invalid threshold format is rejected in CLI mode
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidThresholdRejected() {
|
||||
const testName = "reputation_check: invalid threshold is rejected";
|
||||
try {
|
||||
const result = await runScript(CHECKER_SCRIPT, ['test-skill', '1.0.0', 'abc']);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid threshold")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 1 with invalid threshold message, got code ${result.code}: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer rejects invalid skill name
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidSkill() {
|
||||
const testName = "enhanced_install: rejects skill name with invalid characters";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, ['--skill', 'bad skill!']);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --skill value")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with invalid skill error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer requires --skill argument
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRequiresSkill() {
|
||||
const testName = "enhanced_install: requires --skill argument";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, []);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Missing required argument")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with missing argument error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer rejects invalid threshold
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidThreshold() {
|
||||
const testName = "enhanced_install: rejects invalid reputation threshold";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, [
|
||||
'--skill', 'test-skill', '--reputation-threshold', '150'
|
||||
]);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --reputation-threshold")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 with invalid threshold error, got code ${result.code}: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: formatReputationWarning
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testFormatReputationWarning() {
|
||||
const testName = "reputation: formatReputationWarning formats correctly";
|
||||
try {
|
||||
const { formatReputationWarning } = await import(
|
||||
path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs")
|
||||
);
|
||||
|
||||
// Safe reputation — should return empty
|
||||
const safeResult = formatReputationWarning({ score: 80, warnings: [] });
|
||||
if (safeResult !== "") {
|
||||
fail(testName, `Expected empty string for safe score, got: "${safeResult}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unsafe reputation — should contain warning
|
||||
const unsafeResult = formatReputationWarning({ score: 45, warnings: ["Low downloads", "New author"] });
|
||||
if (
|
||||
unsafeResult.includes("REPUTATION WARNING") &&
|
||||
unsafeResult.includes("45/100") &&
|
||||
unsafeResult.includes("Low downloads") &&
|
||||
unsafeResult.includes("New author")
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Unexpected format: "${unsafeResult}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: formatReputationWarning handles null/undefined
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testFormatReputationWarningNull() {
|
||||
const testName = "reputation: formatReputationWarning handles null input";
|
||||
try {
|
||||
const { formatReputationWarning } = await import(
|
||||
path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib", "reputation.mjs")
|
||||
);
|
||||
|
||||
const nullResult = formatReputationWarning(null);
|
||||
const undefinedResult = formatReputationWarning(undefined);
|
||||
|
||||
if (nullResult === "" && undefinedResult === "") {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected empty for null/undefined, got: "${nullResult}", "${undefinedResult}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Enhanced installer validates --version even with --confirm-reputation
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testEnhancedInstallerRejectsInvalidVersion() {
|
||||
const testName = "enhanced_install: rejects invalid version format even with --confirm-reputation";
|
||||
try {
|
||||
const result = await runScript(ENHANCED_INSTALL_SCRIPT, [
|
||||
'--skill', 'test-skill', '--version', '1.0.0;rm -rf /', '--confirm-reputation'
|
||||
]);
|
||||
|
||||
if (result.code === 1 && result.stderr.includes("Invalid --version value")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(
|
||||
testName,
|
||||
`Expected exit 1 with invalid version message, got code ${result.code}: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main test runner
|
||||
// -----------------------------------------------------------------------------
|
||||
async function runTests() {
|
||||
console.log("=== ClawSec ClawHub Checker Tests ===\n");
|
||||
|
||||
await testInvalidSlugRejected();
|
||||
await testInvalidVersionRejected();
|
||||
await testValidInputsAccepted();
|
||||
await testUppercaseSlugRejected();
|
||||
await testEmptySlugShowsUsage();
|
||||
await testPreReleaseVersionAccepted();
|
||||
await testRelativePathCliEntrypointWorks();
|
||||
await testInvalidThresholdRejected();
|
||||
await testEnhancedInstallerRejectsInvalidSkill();
|
||||
await testEnhancedInstallerRequiresSkill();
|
||||
await testEnhancedInstallerRejectsInvalidVersion();
|
||||
await testEnhancedInstallerRejectsInvalidThreshold();
|
||||
await testFormatReputationWarning();
|
||||
await testFormatReputationWarningNull();
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
console.error("Test runner failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -62,4 +62,4 @@ HIGH - GA-2026-016: Vulnerable skill "data-helper"
|
||||
|
||||
## License
|
||||
|
||||
MIT License - [Prompt Security](https://prompt.security)
|
||||
GNU AGPL v3.0 or later - [Prompt Security](https://prompt.security)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-feed
|
||||
version: 0.0.2
|
||||
version: 0.0.4
|
||||
description: Security advisory feed with automated NVD CVE polling for OpenClaw-related vulnerabilities. Updated daily.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"📡","category":"security"}}
|
||||
@@ -55,6 +55,8 @@ Continue below for standalone installation instructions.
|
||||
---
|
||||
|
||||
Installation Steps:
|
||||
Agents should review scripts before execution!
|
||||
|
||||
|
||||
```bash
|
||||
# Get latest clawsec-feed release tag
|
||||
@@ -640,8 +642,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 +671,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.
|
||||
|
||||
@@ -1,12 +1,553 @@
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"updated": "2026-02-05T12:53:37Z",
|
||||
"version": "0.0.3",
|
||||
"updated": "2026-02-24T06:20:16Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-27576",
|
||||
"severity": "medium",
|
||||
"type": "uncontrolled_resource_consumption",
|
||||
"nvd_category_id": "CWE-400",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the ACP bridge accepts very la...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the ACP bridge accepts very large prompt text blocks and can assemble oversized prompt payloads before forwarding them to chat.send. Because ACP runs over local stdio, this mainly affects local ACP clients (for example IDE integrations) that send unusually large inputs. This issue has been fixed in version 2026.2.19.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:13.437",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/63e39d7f57ac4ad4a5e38d17e7394ae7c4dd0b9c",
|
||||
"https://github.com/openclaw/openclaw/commit/8ae2d5110f6ceadef73822aa3db194fb60d2ba68",
|
||||
"https://github.com/openclaw/openclaw/commit/ebcf19746f5c500a41817e03abecadea8655654a"
|
||||
],
|
||||
"cvss_score": 4.0,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27576"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27488",
|
||||
"severity": "high",
|
||||
"type": "server_side_request_forgery",
|
||||
"nvd_category_id": "CWE-918",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, Cron webhook delivery in src/g...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, Cron webhook delivery in src/gateway/server-cron.ts uses fetch() directly, so webhook targets can reach private/metadata/internal endpoints without SSRF policy checks. This issue was fixed in version 2026.2.19.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:13.267",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/99db4d13e5c139883ef0def9ff963e9273179655",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.19",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-w45g-5746-x9fp"
|
||||
],
|
||||
"cvss_score": 7.3,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27488"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27487",
|
||||
"severity": "high",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.13 and below, when using macOS, the Claude C...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.13 and below, when using macOS, the Claude CLI keychain credential refresh path constructed a shell command to write the updated JSON blob into Keychain via security add-generic-password -w .... Because OAuth tokens are user-controlled data, this created an OS command injection risk. This issue has been fixed in version 2026.2.14.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:13.100",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/66d7178f2d6f9d60abad35797f97f3e61389b70c",
|
||||
"https://github.com/openclaw/openclaw/commit/9dce3d8bf83f13c067bc3c32291643d2f1f10a06",
|
||||
"https://github.com/openclaw/openclaw/commit/b908388245764fb3586859f44d1dff5372b19caf"
|
||||
],
|
||||
"cvss_score": 7.6,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27487"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27486",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_283",
|
||||
"nvd_category_id": "CWE-283",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.13 and below of the OpenClaw CLI, the proces...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.13 and below of the OpenClaw CLI, the process cleanup uses system-wide process enumeration and pattern matching to terminate processes without verifying if they are owned by the current OpenClaw process. On shared hosts, unrelated processes can be terminated if they match the pattern. The CLI runner cleanup helpers can kill processes matched by command-line patterns without validating process ownership. This issue has been fixed in version 2026.2.14.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:12.903",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/6084d13b956119e3cf95daaf9a1cae1670ea3557",
|
||||
"https://github.com/openclaw/openclaw/commit/eb60e2e1b213740c3c587a7ba4dbf10da620ca66",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14"
|
||||
],
|
||||
"cvss_score": null,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27486"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27485",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_61",
|
||||
"nvd_category_id": "CWE-61",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, skills/skill-creator/scripts/p...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, skills/skill-creator/scripts/package_skill.py (a local helper script used when authors package skills) previously followed symlinks while building .skill archives. If an author runs this script on a crafted local skill directory containing symlinks to files outside the skill root, the resulting archive can include unintended file contents. If exploited, this vulnerability can lead to potential unintentional disclosure of local files from the packaging machine into a generated .skill artifact, but requires local execution of the packaging script on attacker-controlled skill contents. This issue has been fixed in version 2026.2.18.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:12.723",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/c275932aa4230fb7a8212fe1b9d2a18424874b3f",
|
||||
"https://github.com/openclaw/openclaw/commit/ee1d6427b544ccadd73e02b1630ea5c29ba9a9f0",
|
||||
"https://github.com/openclaw/openclaw/pull/20796"
|
||||
],
|
||||
"cvss_score": 4.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27485"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27484",
|
||||
"severity": "medium",
|
||||
"type": "missing_authorization",
|
||||
"nvd_category_id": "CWE-862",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the Discord moderation action ...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the Discord moderation action handling (timeout, kick, ban) uses sender identity from request parameters in tool-driven flows, instead of trusted runtime sender context. In setups where Discord moderation actions are enabled and the bot has the necessary guild permissions, a non-admin user can request moderation actions by spoofing sender identity fields. This issue has been fixed in version 2026.2.18.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-21T10:16:12.557",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/775816035ecc6bb243843f8000c9a58ff609e32d",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.19",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-wh94-p5m6-mr7j"
|
||||
],
|
||||
"cvss_score": 4.3,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27484"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27009",
|
||||
"severity": "medium",
|
||||
"type": "cross_site_scripting",
|
||||
"nvd_category_id": "CWE-79",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a atored XSS issue in the OpenClaw ...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a atored XSS issue in the OpenClaw Control UI when rendering assistant identity (name/avatar) into an inline `<script>` tag without script-context-safe escaping. A crafted value containing `</script>` could break out of the script tag and execute attacker-controlled JavaScript in the Control UI origin. Version 2026.2.15 removed inline script injection and serve bootstrap config from a JSON endpoint and added a restrictive Content Security Policy for the Control UI (`script-src 'self'`, no inline scripts).",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:17.620",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/3b4096e02e7e335f99f5986ec1bd566e90b14a7e",
|
||||
"https://github.com/openclaw/openclaw/commit/adc818db4a4b3b8d663e7674ef20436947514e1b",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15"
|
||||
],
|
||||
"cvss_score": 5.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27009"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27008",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_73",
|
||||
"nvd_category_id": "CWE-73",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a bug in `download` skill installat...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a bug in `download` skill installation allowed `targetDir` values from skill frontmatter to resolve outside the per-skill tools directory if not strictly validated. In the admin-only `skills.install` flow, this could write files outside the intended install sandbox. Version 2026.2.15 contains a fix for the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:17.460",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/2363e1b0853a028e47f90dcc1066e3e9809d65f1",
|
||||
"https://github.com/openclaw/openclaw/commit/b6305e97256d67e439719faacf5af3de9727d6e1",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15"
|
||||
],
|
||||
"cvss_score": 6.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27008"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27007",
|
||||
"severity": "low",
|
||||
"type": "unknown_cwe_1254",
|
||||
"nvd_category_id": "CWE-1254",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, `normalizeForHash` in `src/agents/s...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, `normalizeForHash` in `src/agents/sandbox/config-hash.ts` recursively sorted arrays that contained only primitive values. This made order-sensitive sandbox configuration arrays hash to the same value even when order changed. In OpenClaw sandbox flows, this hash is used to decide whether existing sandbox containers should be recreated. As a result, order-only config changes (for example Docker `dns` and `binds` array order) could be treated as unchanged and stale containers could be reused. This is a configuration integrity issue affecting sandbox recreation behavior. Starting in version 2026.2.15, array ordering is preserved during hash normalization; only object key ordering remains normalized for deterministic hashing.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:17.303",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/41ded303b4f6dae5afa854531ff837c3276ad60b",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-xxvh-5hwj-42pp"
|
||||
],
|
||||
"cvss_score": 3.3,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27007"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27004",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_209",
|
||||
"nvd_category_id": "CWE-209",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, in some shared-agent deployments, O...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, in some shared-agent deployments, OpenClaw session tools (`sessions_list`, `sessions_history`, `sessions_send`) allowed broader session targeting than some operators intended. This is primarily a configuration/visibility-scoping issue in multi-user environments where peers are not equally trusted. In Telegram webhook mode, monitor startup also did not fall back to per-account `webhookSecret` when only the account-level secret was configured. In shared-agent, multi-user, less-trusted environments: session-tool access could expose transcript content across peer sessions. In single-agent or trusted environments, practical impact is limited. In Telegram webhook mode, account-level secret wiring could be missed unless an explicit monitor webhook secret override was provided. Version 2026.2.15 fixes the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:17.140",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/c6c53437f7da033b94a01d492e904974e7bda74c",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-6hf3-mhgc-cm65"
|
||||
],
|
||||
"cvss_score": 5.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27004"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27003",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_522",
|
||||
"nvd_category_id": "CWE-522",
|
||||
"title": "OpenClaw is a personal AI assistant. Telegram bot tokens can appear in error messages and stack trac...",
|
||||
"description": "OpenClaw is a personal AI assistant. Telegram bot tokens can appear in error messages and stack traces (for example, when request URLs include `https://api.telegram.org/bot<token>/...`). Prior to version 2026.2.15, OpenClaw logged these strings without redaction, which could leak the bot token into logs, crash reports, CI output, or support bundles. Disclosure of a Telegram bot token allows an attacker to impersonate the bot and take over Bot API access. Users should upgrade to version 2026.2.15 to obtain a fix and rotate the Telegram bot token if it may have been exposed.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:16.983",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/cf69907015b659e5025efb735ee31bd05c4ee3d5",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-chf7-jq6g-qrwv"
|
||||
],
|
||||
"cvss_score": 5.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27003"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27002",
|
||||
"severity": "critical",
|
||||
"type": "execution_with_unnecessary_privileges",
|
||||
"nvd_category_id": "CWE-250",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a configuration injection issue in ...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a configuration injection issue in the Docker tool sandbox could allow dangerous Docker options (bind mounts, host networking, unconfined profiles) to be applied, enabling container escape or host data access. OpenClaw 2026.2.15 blocks dangerous sandbox Docker settings and includes runtime enforcement when building `docker create` args; config-schema validation for `network=host`, `seccompProfile=unconfined`, `apparmorProfile=unconfined`; and security audit findings to surface dangerous sandbox docker config. As a workaround, do not configure `agents.*.sandbox.docker.binds` to mount system directories or Docker socket paths, keep `agents.*.sandbox.docker.network` at `none` (default) or `bridge`, and do not use `unconfined` for seccomp/AppArmor profiles.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:16.827",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/887b209db47f1f9322fead241a1c0b043fd38339",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-w235-x559-36mg"
|
||||
],
|
||||
"cvss_score": 9.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27002"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-27001",
|
||||
"severity": "high",
|
||||
"type": "command_injection",
|
||||
"nvd_category_id": "CWE-77",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, OpenClaw embedded the current worki...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.15, OpenClaw embedded the current working directory (workspace path) into the agent system prompt without sanitization. If an attacker can cause OpenClaw to run inside a directory whose name contains control/format characters (for example newlines or Unicode bidi/zero-width markers), those characters could break the prompt structure and inject attacker-controlled instructions. Starting in version 2026.2.15, the workspace path is sanitized before it is embedded into any LLM prompt output, stripping Unicode control/format characters and explicit line/paragraph separators. Workspace path resolution also applies the same sanitization as defense-in-depth.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:16.653",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/6254e96acf16e70ceccc8f9b2abecee44d606f79",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.15",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-2qj5-gwg2-xwc4"
|
||||
],
|
||||
"cvss_score": 7.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27001"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26972",
|
||||
"severity": "medium",
|
||||
"type": "path_traversal",
|
||||
"nvd_category_id": "CWE-22",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.1.12 through 2026.2.12, OpenClaw browser downl...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.1.12 through 2026.2.12, OpenClaw browser download helpers accepted an unsanitized output path. When invoked via the browser control gateway routes, this allowed path traversal to write downloads outside the intended OpenClaw temp downloads directory. This issue is not exposed via the AI agent tool schema (no `download` action). Exploitation requires authenticated CLI access or an authenticated gateway RPC token. Version 2026.2.13 fixes the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:16.500",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/7f0489e4731c8d965d78d6eac4a60312e46a9426",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.13",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-xwjm-j929-xq7c"
|
||||
],
|
||||
"cvss_score": 6.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26972"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26329",
|
||||
"severity": "medium",
|
||||
"type": "path_traversal",
|
||||
"nvd_category_id": "CWE-22",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, authenticated attackers can read ar...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, authenticated attackers can read arbitrary files from the Gateway host by supplying absolute paths or path traversal sequences to the browser tool's `upload` action. The server passed these paths to Playwright's `setInputFiles()` APIs without restricting them to a safe root. An attacker must reach the Gateway HTTP surface (or otherwise invoke the same browser control hook endpoints); present valid Gateway auth (bearer token / password), as required by the Gateway configuration (In common default setups, the Gateway binds to loopback and the onboarding wizard generates a gateway token even for loopback); and have the `browser` tool permitted by tool policy for the target session/context (and have browser support enabled). If an operator exposes the Gateway beyond loopback (LAN/tailnet/custom bind, reverse proxy, tunnels, etc.), the impact increases accordingly. Starting in version 2026.2.14, the upload paths are now confined to OpenClaw's temp uploads root (`DEFAULT_UPLOAD_DIR`) and traversal/escape paths are rejected.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:15.687",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/3aa94afcfd12104c683c9cad81faf434d0dadf87",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-cv7m-c9jx-vg7q"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26329"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26328",
|
||||
"severity": "medium",
|
||||
"type": "improper_access_control",
|
||||
"nvd_category_id": "CWE-284",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, under iMessage `groupPolicy=allowli...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, under iMessage `groupPolicy=allowlist`, group authorization could be satisfied by sender identities coming from the DM pairing store, broadening DM trust into group contexts. Version 2026.2.14 fixes the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-20T00:16:15.523",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/872079d42fe105ece2900a1dd6ab321b92da2d59",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g34w-4xqq-h79m"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26328"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26327",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_345",
|
||||
"nvd_category_id": "CWE-345",
|
||||
"title": "OpenClaw is a personal AI assistant. Discovery beacons (Bonjour/mDNS and DNS-SD) include TXT records...",
|
||||
"description": "OpenClaw is a personal AI assistant. Discovery beacons (Bonjour/mDNS and DNS-SD) include TXT records such as `lanHost`, `tailnetDns`, `gatewayPort`, and `gatewayTlsSha256`. TXT records are unauthenticated. Prior to version 2026.2.14, some clients treated TXT values as authoritative routing/pinning inputs. iOS and macOS used TXT-provided host hints (`lanHost`/`tailnetDns`) and ports (`gatewayPort`) to build the connection URL. iOS and Android allowed the discovery-provided TLS fingerprint (`gatewayTlsSha256`) to override a previously stored TLS pin. On a shared/untrusted LAN, an attacker could advertise a rogue `_openclaw-gw._tcp` service. This could cause a client to connect to an attacker-controlled endpoint and/or accept an attacker certificate, potentially exfiltrating Gateway credentials (`auth.token` / `auth.password`) during connection. As of time of publication, the iOS and Android apps are alpha/not broadly shipped (no public App Store / Play Store release). Practical impact is primarily limited to developers/testers running those builds, plus any other shipped clients relying on discovery on a shared/untrusted LAN. Version 2026.2.14 fixes the issue. Clients now prefer the resolved service endpoint (SRV + A/AAAA) over TXT-provided routing hints. Discovery-provided fingerprints no longer override stored TLS pins. In iOS/Android, first-time TLS pins require explicit user confirmation (fingerprint shown; no silent TOFU) and discovery-based direct connects are TLS-only. In Android, hostname verification is no longer globally disabled (only bypassed when pinning).",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:26.100",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/d583782ee322a6faa1fe87ae52455e0d349de586",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-pv58-549p-qh99"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26327"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26326",
|
||||
"severity": "medium",
|
||||
"type": "exposure_of_sensitive_information",
|
||||
"nvd_category_id": "CWE-200",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, `skills.status` could disclose secr...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, `skills.status` could disclose secrets to `operator.read` clients by returning raw resolved config values in `configChecks` for skill `requires.config` paths. Version 2026.2.14 stops including raw resolved config values in requirement checks (return only `{ path, satisfied }`) and narrows the Discord skill requirement to the token key. In addition to upgrading, users should rotate any Discord tokens that may have been exposed to read-scoped clients.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.950",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/d3428053d95eefbe10ecf04f92218ffcba55ae5a",
|
||||
"https://github.com/openclaw/openclaw/commit/ebc68861a61067fc37f9298bded3eec9de0ba783",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14"
|
||||
],
|
||||
"cvss_score": 4.3,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26326"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26325",
|
||||
"severity": "high",
|
||||
"type": "improper_access_control",
|
||||
"nvd_category_id": "CWE-284",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, a mismatch between `rawCommand` and...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, a mismatch between `rawCommand` and `command[]` in the node host `system.run` handler could cause allowlist/approval evaluation to be performed on one command while executing a different argv. This only impacts deployments that use the node host / companion node execution path (`system.run` on a node), enable allowlist-based exec policy (`security=allowlist`) with approval prompting driven by allowlist misses (for example `ask=on-miss`), allow an attacker to invoke `system.run`. Default/non-node configurations are not affected. Version 2026.2.14 enforces `rawCommand`/`command[]` consistency (gateway fail-fast + node host validation).",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.800",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/cb3290fca32593956638f161d9776266b90ab891",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-h3f9-mjwj-w476"
|
||||
],
|
||||
"cvss_score": 7.2,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26325"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26324",
|
||||
"severity": "high",
|
||||
"type": "server_side_request_forgery",
|
||||
"nvd_category_id": "CWE-918",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, OpenClaw's SSRF protection could be...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.2.14, OpenClaw's SSRF protection could be bypassed using full-form IPv4-mapped IPv6 literals such as `0:0:0:0:0:ffff:7f00:1` (which is `127.0.0.1`). This could allow requests that should be blocked (loopback / private network / link-local metadata) to pass the SSRF guard. Version 2026.2.14 patches the issue.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.653",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/c0c0e0f9aecb913e738742f73e091f2f72d39a19",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-jrvc-8ff5-2f9f"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26324"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26323",
|
||||
"severity": "high",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw is a personal AI assistant. Versions 2026.1.8 through 2026.2.13 have a command injection in...",
|
||||
"description": "OpenClaw is a personal AI assistant. Versions 2026.1.8 through 2026.2.13 have a command injection in the maintainer/dev script `scripts/update-clawtributors.ts`. The issue affects contributors/maintainers (or CI) who run `bun scripts/update-clawtributors.ts` in a source checkout that contains a malicious commit author email (e.g. crafted `@users[.]noreply[.]github[.]com` values). Normal CLI usage is not affected (`npm i -g openclaw`): this script is not part of the shipped CLI and is not executed during routine operation. The script derived a GitHub login from `git log` author metadata and interpolated it into a shell command (via `execSync`). A malicious commit record could inject shell metacharacters and execute arbitrary commands when the script is run. Version 2026.2.14 contains a patch.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.500",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/a429380e337152746031d290432a4b93aa553d55",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-m7x8-2w3w-pr42"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26323"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26322",
|
||||
"severity": "high",
|
||||
"type": "server_side_request_forgery",
|
||||
"nvd_category_id": "CWE-918",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to OpenClaw version 2026.2.14, the Gateway tool accepted ...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to OpenClaw version 2026.2.14, the Gateway tool accepted a tool-supplied `gatewayUrl` without sufficient restrictions, which could cause the OpenClaw host to attempt outbound WebSocket connections to user-specified targets. This requires the ability to invoke tools that accept `gatewayUrl` overrides (directly or indirectly). In typical setups this is limited to authenticated operators, trusted automation, or environments where tool calls are exposed to non-operators. In other words, this is not a drive-by issue for arbitrary internet users unless a deployment explicitly allows untrusted users to trigger these tool calls. Some tool call paths allowed `gatewayUrl` overrides to flow into the Gateway WebSocket client without validation or allowlisting. This meant the host could be instructed to attempt connections to non-gateway endpoints (for example, localhost services, private network addresses, or cloud metadata IPs). In the common case, this results in an outbound connection attempt from the OpenClaw host (and corresponding errors/timeouts). In environments where the tool caller can observe the results, this can also be used for limited network reachability probing. If the target speaks WebSocket and is reachable, further interaction may be possible. Starting in version 2026.2.14, tool-supplied `gatewayUrl` overrides are restricted to loopback (on the configured gateway port) or the configured `gateway.remote.url`. Disallowed protocols, credentials, query/hash, and non-root paths are rejected.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.340",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/c5406e1d2434be2ef6eb4d26d8f1798d718713f4",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g6q9-8fvw-f7rf"
|
||||
],
|
||||
"cvss_score": 7.6,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26322"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26321",
|
||||
"severity": "high",
|
||||
"type": "path_traversal",
|
||||
"nvd_category_id": "CWE-22",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to OpenClaw version 2026.2.14, the Feishu extension previ...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to OpenClaw version 2026.2.14, the Feishu extension previously allowed `sendMediaFeishu` to treat attacker-controlled `mediaUrl` values as local filesystem paths and read them directly. If an attacker can influence tool calls (directly or via prompt injection), they may be able to exfiltrate local files by supplying paths such as `/etc/passwd` as `mediaUrl`. Upgrade to OpenClaw `2026.2.14` or newer to receive a fix. The fix removes direct local file reads from this path and routes media loading through hardened helpers that enforce local-root restrictions.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.180",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/5b4121d6011a48c71e747e3c18197f180b872c5d",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-8jpq-5h99-ff5r"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26321"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26320",
|
||||
"severity": "medium",
|
||||
"type": "unknown_cwe_451",
|
||||
"nvd_category_id": "CWE-451",
|
||||
"title": "OpenClaw is a personal AI assistant. OpenClaw macOS desktop client registers the `openclaw://` URL s...",
|
||||
"description": "OpenClaw is a personal AI assistant. OpenClaw macOS desktop client registers the `openclaw://` URL scheme. For `openclaw://agent` deep links without an unattended `key`, the app shows a confirmation dialog that previously displayed only the first 240 characters of the message, but executed the full message after the user clicked \"Run.\" At the time of writing, the OpenClaw macOS desktop client is still in beta. In versions 2026.2.6 through 2026.2.13, an attacker could pad the message with whitespace to push a malicious payload outside the visible preview, increasing the chance a user approves a different message than the one that is actually executed. If a user runs the deep link, the agent may perform actions that can lead to arbitrary command execution depending on the user's configured tool approvals/allowlists. This is a social-engineering mediated vulnerability: the confirmation prompt could be made to misrepresent the executed message. The issue is fixed in 2026.2.14. Other mitigations include not approve unexpected \"Run OpenClaw agent?\" prompts triggered while browsing untrusted sites and usingunattended deep links only with a valid `key` for trusted personal automations.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:25.017",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/28d9dd7a772501ccc3f71457b4adfee79084fe6f",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-7q2j-c4q5-rm27"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26320"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26319",
|
||||
"severity": "high",
|
||||
"type": "missing_authentication_for_critical_function",
|
||||
"nvd_category_id": "CWE-306",
|
||||
"title": "OpenClaw is a personal AI assistant. Versions 2026.2.13 and below allow the optional @openclaw/voice...",
|
||||
"description": "OpenClaw is a personal AI assistant. Versions 2026.2.13 and below allow the optional @openclaw/voice-call plugin Telnyx webhook handler to accept unsigned inbound webhook requests when telnyx.publicKey is not configured, enabling unauthenticated callers to forge Telnyx events. Telnyx webhooks are expected to be authenticated via Ed25519 signature verification. In affected versions, TelnyxProvider.verifyWebhook() could effectively fail open when no Telnyx public key was configured, allowing arbitrary HTTP POST requests to the voice-call webhook endpoint to be treated as legitimate Telnyx events. This only impacts deployments where the Voice Call plugin is installed, enabled, and the webhook endpoint is reachable from the attacker (for example, publicly exposed via a tunnel/proxy). The issue has been fixed in version 2026.2.14.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T23:16:24.857",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/29b587e73cbdc941caec573facd16e87d52f007b",
|
||||
"https://github.com/openclaw/openclaw/commit/f47584fec86d6d73f2d483043a2ad0e7e3c50411",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26319"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26317",
|
||||
"severity": "high",
|
||||
"type": "cross_site_request_forgery",
|
||||
"nvd_category_id": "CWE-352",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.2.14, browser-facing localhost mutation routes ac...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.2.14, browser-facing localhost mutation routes accepted cross-origin browser requests without explicit Origin/Referer validation. Loopback binding reduces remote exposure but does not prevent browser-initiated requests from malicious origins. A malicious website can trigger unauthorized state changes against a victim's local OpenClaw browser control plane (for example opening tabs, starting/stopping the browser, mutating storage/cookies) if the browser control service is reachable on loopback in the victim's browser context. Starting in version 2026.2.14, mutating HTTP methods (POST/PUT/PATCH/DELETE) are rejected when the request indicates a non-loopback Origin/Referer (or `Sec-Fetch-Site: cross-site`). Other mitigations include enabling browser control auth (token/password) and avoid running with auth disabled.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T22:16:47.270",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/b566b09f81e2b704bf9398d8d97d5f7a90aa94c3",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.14",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-3fqr-4cg8-h96q"
|
||||
],
|
||||
"cvss_score": 7.1,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26317"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-26316",
|
||||
"severity": "high",
|
||||
"type": "incorrect_authorization",
|
||||
"nvd_category_id": "CWE-863",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.2.13, the optional BlueBubbles iMessage channel p...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.2.13, the optional BlueBubbles iMessage channel plugin could accept webhook requests as authenticated based only on the TCP peer address being loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) even when the configured webhook secret was missing or incorrect. This does not affect the default iMessage integration unless BlueBubbles is installed and enabled. Version 2026.2.13 contains a patch. Other mitigations include setting a non-empty BlueBubbles webhook password and avoiding deployments where a public-facing reverse proxy forwards to a loopback-bound Gateway without strong upstream authentication.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T22:16:47.110",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/743f4b28495cdeb0d5bf76f6ebf4af01f6a02e5a",
|
||||
"https://github.com/openclaw/openclaw/commit/f836c385ffc746cb954e8ee409f99d079bfdcd2f",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.13"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26316"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25474",
|
||||
"severity": "high",
|
||||
"type": "unknown_cwe_345",
|
||||
"nvd_category_id": "CWE-345",
|
||||
"title": "OpenClaw is a personal AI assistant. In versions 2026.1.30 and below, if channels.telegram.webhookSe...",
|
||||
"description": "OpenClaw is a personal AI assistant. In versions 2026.1.30 and below, if channels.telegram.webhookSecret is not set when in Telegram webhook mode, OpenClaw may accept webhook HTTP requests without verifying Telegram’s secret token header. In deployments where the webhook endpoint is reachable by an attacker, this can allow forged Telegram updates (for example spoofing message.from.id). If an attacker can reach the webhook endpoint, they may be able to send forged updates that are processed as if they came from Telegram. Depending on enabled commands/tools and configuration, this could lead to unintended bot actions. Note: Telegram webhook mode is not enabled by default. It is enabled only when `channels.telegram.webhookUrl` is configured. This issue has been fixed in version 2026.2.1.",
|
||||
"affected": [
|
||||
"cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*"
|
||||
],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T07:17:45.847",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/3cbcba10cf30c2ffb898f0d8c7dfb929f15f8930",
|
||||
"https://github.com/openclaw/openclaw/commit/5643a934799dc523ec2ef18c007e1aa2c386b670",
|
||||
"https://github.com/openclaw/openclaw/commit/633fe8b9c17f02fcc68ecdb5ec212a5ace932f09"
|
||||
],
|
||||
"cvss_score": 7.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25474"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24764",
|
||||
"severity": "low",
|
||||
"type": "unknown_cwe_74",
|
||||
"nvd_category_id": "CWE-74",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant users run on their own devices. In versions ...",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant users run on their own devices. In versions 2026.2.2 and below, when the Slack integration is enabled, channel metadata (topic/description) can be incorporated into the model's system prompt. Prompt injection is a documented risk for LLM-driven systems. This issue increases the injection surface by allowing untrusted Slack channel metadata to be treated as higher-trust system input. This issue has been fixed in version 2026.2.3.",
|
||||
"affected": [
|
||||
"cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*"
|
||||
],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-19T07:17:44.957",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/35eb40a7000b59085e9c638a80fd03917c7a095e",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.2.3",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-782p-5fr5-7fj8"
|
||||
],
|
||||
"cvss_score": 3.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24764"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "missing_authentication_for_critical_function",
|
||||
"nvd_category_id": "CWE-306",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "exposure_of_sensitive_information",
|
||||
"nvd_category_id": "CWE-200",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.",
|
||||
"affected": [],
|
||||
@@ -21,7 +562,8 @@
|
||||
{
|
||||
"id": "CVE-2026-25157",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.",
|
||||
"affected": [],
|
||||
@@ -33,32 +575,13 @@
|
||||
"cvss_score": 7.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157"
|
||||
},
|
||||
{
|
||||
"id": "CLAW-2026-0001",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection",
|
||||
"title": "Data exfiltration attempt via helper-plus skill",
|
||||
"description": "The helper-plus skill was observed sending conversation data to an external server (suspicious-domain.com) on every invocation. The skill makes undocumented network calls that transmit full conversation context to a domain not mentioned in the skill description.",
|
||||
"affected": [
|
||||
"helper-plus@1.0.0",
|
||||
"helper-plus@1.0.1"
|
||||
],
|
||||
"action": "Remove helper-plus immediately. Do not use versions 1.0.0 or 1.0.1. Wait for a verified patched version.",
|
||||
"published": "2026-02-04T09:30:00Z",
|
||||
"references": [],
|
||||
"source": "Community Report",
|
||||
"github_issue_url": "https://github.com/prompt-security/clawsec/issues/1",
|
||||
"reporter": {
|
||||
"agent_name": "SecurityBot",
|
||||
"opener_type": "agent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24763",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw's Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
@@ -73,7 +596,8 @@
|
||||
{
|
||||
"id": "CVE-2026-25253",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "incorrect_resource_transfer_between_spheres",
|
||||
"nvd_category_id": "CWE-669",
|
||||
"title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...",
|
||||
"description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.",
|
||||
"affected": [],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Rs++ntJvBvX4zVTJ/DsrfXOQG3VTUc2x4esSURSMonesmYzSm9U9kd3rBz5d+DemJOVJ/esH21VACpdE+T34AA==
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "clawsec-feed",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.4",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# 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
|
||||
- **Platform Targeting**: Advisories can be NanoClaw-specific or cross-platform
|
||||
|
||||
## 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**: `host/ipc-handler.ts`
|
||||
|
||||
```typescript
|
||||
// Add this import at the top
|
||||
import { registerClawSecHandlers } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
|
||||
// In your IPC handler setup function
|
||||
export function setupIpcHandlers() {
|
||||
// ... your existing handlers ...
|
||||
|
||||
// Register ClawSec handlers
|
||||
registerClawSecHandlers();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Start Advisory Cache Service
|
||||
|
||||
Add the advisory cache manager to your host services.
|
||||
|
||||
**File**: `host/index.ts` (or your main entry point)
|
||||
|
||||
```typescript
|
||||
// Add this import
|
||||
import { startAdvisoryCache } from '../skills/clawsec-nanoclaw/host-services/advisory-cache.js';
|
||||
|
||||
// Start the service when your host process starts
|
||||
async function main() {
|
||||
// ... your existing initialization ...
|
||||
|
||||
// Start ClawSec advisory cache (fetches feed every 6 hours)
|
||||
startAdvisoryCache({
|
||||
cacheFile: '/workspace/project/data/clawsec-advisory-cache.json',
|
||||
feedUrl: 'https://clawsec.prompt.security/advisories/feed.json',
|
||||
publicKeyPath: '/workspace/project/skills/clawsec-nanoclaw/advisories/feed-signing-public.pem',
|
||||
refreshInterval: 6 * 60 * 60 * 1000, // 6 hours
|
||||
});
|
||||
|
||||
// ... 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
|
||||
|
||||
**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
|
||||
- `signature`: Ed25519 signature
|
||||
- `lastFetch`: Timestamp of last update
|
||||
- `verified`: Should be `true`
|
||||
|
||||
## 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({
|
||||
skillsRoot: '/workspace/project/skills'
|
||||
});
|
||||
|
||||
// Check specific skill before installation
|
||||
const safetyCheck = await tools.clawsec_check_skill_safety({
|
||||
skillName: 'risky-skill',
|
||||
version: '1.0.0'
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Cache Location
|
||||
|
||||
Default: `/workspace/project/data/clawsec-advisory-cache.json`
|
||||
|
||||
To change, update the `cacheFile` parameter in `startAdvisoryCache()`.
|
||||
|
||||
### Refresh Interval
|
||||
|
||||
Default: 6 hours
|
||||
|
||||
To change, update the `refreshInterval` parameter (in milliseconds).
|
||||
|
||||
### Feed URL
|
||||
|
||||
Default: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
|
||||
To use a mirror or custom feed, update the `feedUrl` parameter.
|
||||
|
||||
## 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
|
||||
|
||||
The MCP tools automatically filter advisories based on your platform.
|
||||
|
||||
## 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 `startAdvisoryCache()` 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 `host/ipc-handler.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 `host/ipc-handler.ts`
|
||||
3. Remove `startAdvisoryCache()` call 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**: Automatic feed fetching every 6 hours
|
||||
- **Signature Verification**: Ed25519-signed feeds ensure integrity
|
||||
- **Platform Filtering**: Shows only relevant advisories for NanoClaw
|
||||
- **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 { clawsecTools } from '../../../skills/clawsec-nanoclaw/mcp-tools/advisory-tools.js';
|
||||
```
|
||||
|
||||
**2. IPC Handlers** (host):
|
||||
```typescript
|
||||
// host/ipc-handler.ts
|
||||
import { registerClawSecHandlers } from '../skills/clawsec-nanoclaw/host-services/ipc-handlers.js';
|
||||
```
|
||||
|
||||
**3. Cache Service** (host):
|
||||
```typescript
|
||||
// host/index.ts
|
||||
import { startAdvisoryCache } 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](../../docs/SECURITY-SIGNING.md) - Signature verification details
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: https://github.com/prompt-security/clawsec/issues
|
||||
- **Security**: security@prompt.security
|
||||
- NanoClaw Repository: (link TBD)
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
name: clawsec-nanoclaw
|
||||
version: 0.0.1
|
||||
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 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',
|
||||
version: '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
|
||||
const result = await tools.clawsec_check_advisories({
|
||||
skillsRoot: '/workspace/project/skills' // optional
|
||||
});
|
||||
|
||||
if (result.criticalCount > 0) {
|
||||
// Alert user immediately
|
||||
console.error('CRITICAL vulnerabilities found!');
|
||||
}
|
||||
```
|
||||
|
||||
### Browse Advisories
|
||||
|
||||
```typescript
|
||||
// List advisories with filters
|
||||
const advisories = await tools.clawsec_list_advisories({
|
||||
platform: 'nanoclaw', // optional: nanoclaw, openclaw, or both
|
||||
severity: 'critical' // optional: critical, high, medium, low
|
||||
});
|
||||
```
|
||||
|
||||
## 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` (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 for security advisories using clawsec_check_advisories and alert if any critical issues found",
|
||||
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 critical issues found.
|
||||
- 2 low-severity advisories (not urgent)
|
||||
- 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 platform filters
|
||||
```typescript
|
||||
// DON'T: Check OpenClaw advisories on NanoClaw
|
||||
const advisories = await tools.clawsec_list_advisories({
|
||||
platform: 'openclaw' // Wrong platform!
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO: Use correct platform or let it auto-filter
|
||||
const advisories = await tools.clawsec_list_advisories({
|
||||
platform: 'nanoclaw' // Correct
|
||||
});
|
||||
```
|
||||
|
||||
### ❌ Skipping critical severity
|
||||
```typescript
|
||||
// DON'T: Only check low severity
|
||||
if (result.lowCount > 0) alert();
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DO: Prioritize critical and high
|
||||
if (result.criticalCount > 0 || result.highCount > 0) {
|
||||
// Alert immediately
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
**Feed Source**: https://clawsec.prompt.security/advisories/feed.json
|
||||
|
||||
**Update Frequency**: Every 6 hours (automatic)
|
||||
|
||||
**Signature Verification**: Ed25519 signed feeds
|
||||
|
||||
**Cache Location**: `/workspace/project/data/clawsec-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,495 @@
|
||||
# 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` or `.zip`)
|
||||
- `signaturePath` (optional): Path to signature file (auto-detects `.sig` if omitted)
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
{
|
||||
success: boolean, // Operation completed without errors
|
||||
valid: boolean, // Signature is cryptographically valid
|
||||
recommendation: string, // "install" | "block" | "review"
|
||||
signer: string, // "clawsec" or custom signer
|
||||
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>
|
||||
```
|
||||
|
||||
### Using Custom Public Keys
|
||||
|
||||
For organizational deployments with custom skill publishers:
|
||||
|
||||
```typescript
|
||||
// Load custom public key
|
||||
const customPublicKey = fs.readFileSync('/path/to/org-public.pem', 'utf8');
|
||||
|
||||
// Verify with custom key (not pinned ClawSec key)
|
||||
const verification = await tools.clawsec_verify_skill_package({
|
||||
packagePath: '/tmp/org-skill.tar.gz',
|
||||
publicKeyPath: '/path/to/org-public.pem' // Custom key
|
||||
});
|
||||
```
|
||||
|
||||
**Note**: The MCP tool currently uses the pinned key. Custom key support via `publicKeyPem` parameter requires host-side implementation.
|
||||
|
||||
### 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,717 @@
|
||||
/**
|
||||
* 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: 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;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Baseline Management
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
private loadBaselines(): BaselinesManifest {
|
||||
if (fs.existsSync(this.baselinesPath)) {
|
||||
const raw = fs.readFileSync(this.baselinesPath, 'utf-8');
|
||||
return 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');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
refuseSymlink(filePath);
|
||||
|
||||
const previousSha = this.baselines.files[filePath]?.sha256;
|
||||
const currentSha = sha256File(filePath);
|
||||
|
||||
// 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, '-')}-approve-${safePatchTag(path.basename(filePath))}.patch`
|
||||
);
|
||||
fs.writeFileSync(patchPath, diff);
|
||||
|
||||
// Update baseline
|
||||
if (!this.baselines.files[filePath]) {
|
||||
// Find mode from policy
|
||||
const targets = this.resolveTargets();
|
||||
const target = targets.find(t => t.path === filePath);
|
||||
if (!target) {
|
||||
throw new Error(`File ${filePath} not in policy`);
|
||||
}
|
||||
|
||||
this.baselines.files[filePath] = {
|
||||
sha256: currentSha,
|
||||
approved_at: utcNowIso(),
|
||||
approved_by: actor,
|
||||
mode: target.mode,
|
||||
priority: target.priority
|
||||
};
|
||||
} else {
|
||||
this.baselines.files[filePath].sha256 = currentSha;
|
||||
this.baselines.files[filePath].approved_at = utcNowIso();
|
||||
this.baselines.files[filePath].approved_by = actor;
|
||||
}
|
||||
|
||||
// Update snapshot
|
||||
fs.copyFileSync(filePath, snapshot);
|
||||
|
||||
// Save and audit
|
||||
this.saveBaselines();
|
||||
|
||||
this.appendAudit({
|
||||
ts: utcNowIso(),
|
||||
event: 'approve',
|
||||
actor,
|
||||
note,
|
||||
path: filePath,
|
||||
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 files = filePath
|
||||
? { [filePath]: this.baselines.files[filePath] }
|
||||
: 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,417 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// 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;
|
||||
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;
|
||||
} {
|
||||
if (advisories.length === 0) {
|
||||
return { safe: true, recommendation: 'install', reason: 'No advisories found' };
|
||||
}
|
||||
|
||||
const hasMalicious = advisories.some((a) => a.type === 'malicious');
|
||||
const hasRemoveAction = advisories.some((a) => a.action === 'remove');
|
||||
const hasCritical = advisories.some((a) => a.severity === 'critical');
|
||||
const hasHigh = advisories.some((a) => a.severity === 'high');
|
||||
|
||||
if (hasMalicious || hasRemoveAction) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Malicious skill or removal recommended',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCritical) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'block',
|
||||
reason: 'Critical security advisory',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHigh) {
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'High severity advisory - user review recommended',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
safe: false,
|
||||
recommendation: 'review',
|
||||
reason: 'Advisory found - review before installing',
|
||||
};
|
||||
}
|
||||
@@ -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,107 @@
|
||||
/**
|
||||
* 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, publicKeyPem, allowUnsigned } = 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,
|
||||
publicKeyPem,
|
||||
allowUnsigned: allowUnsigned || false,
|
||||
});
|
||||
|
||||
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,229 @@
|
||||
/**
|
||||
* 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;
|
||||
publicKeyPem?: string; // Optional override of pinned key
|
||||
allowUnsigned?: boolean; // Allow missing signature (default: false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
publicKeyPem,
|
||||
allowUnsigned = false
|
||||
} = params;
|
||||
|
||||
// Validate package file exists
|
||||
if (!fs.existsSync(packagePath)) {
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: `Package file not found: ${packagePath}`
|
||||
};
|
||||
}
|
||||
|
||||
// Check signature file exists
|
||||
if (!fs.existsSync(signaturePath)) {
|
||||
if (allowUnsigned) {
|
||||
// Unsigned allowed - compute hash but mark invalid
|
||||
const packageHash = sha256File(packagePath);
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: 'No signature file found (unsigned package)'
|
||||
};
|
||||
} else {
|
||||
// Unsigned not allowed - fail
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: `Signature file not found: ${signaturePath}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Load public key (either custom or pinned)
|
||||
let keyPem: string;
|
||||
try {
|
||||
if (publicKeyPem) {
|
||||
// Custom key provided - validate format
|
||||
loadPublicKey(publicKeyPem); // Throws if invalid
|
||||
keyPem = publicKeyPem;
|
||||
} else {
|
||||
// Load pinned ClawSec key
|
||||
if (!fs.existsSync(this.publicKeyPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
signer: null,
|
||||
packageHash: '',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
algorithm: 'Ed25519',
|
||||
error: `Public key file not found: ${this.publicKeyPath}`
|
||||
};
|
||||
}
|
||||
keyPem = fs.readFileSync(this.publicKeyPath, 'utf8');
|
||||
loadPublicKey(keyPem); // Validate pinned key
|
||||
}
|
||||
} 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(packagePath);
|
||||
} 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(
|
||||
packagePath,
|
||||
signaturePath,
|
||||
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,327 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 combined = `${advisory.title} ${advisory.description} ${advisory.action}`.toLowerCase();
|
||||
|
||||
if (type === 'malicious_skill' || type === 'malicious_plugin') return true;
|
||||
if (severity === 'critical') 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) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) continue;
|
||||
|
||||
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If version specified, check if it matches
|
||||
if (version && !versionMatches(version, parsed.versionSpec)) {
|
||||
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,497 @@
|
||||
/**
|
||||
* Ed25519 Signature Verification for NanoClaw
|
||||
* Ported from ClawSec's feed.mjs
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import { ChecksumsManifest } from './types.js';
|
||||
|
||||
/**
|
||||
* Allowed domains for feed/signature fetching.
|
||||
* Only connections to these domains are permitted for security.
|
||||
*/
|
||||
const ALLOWED_DOMAINS = [
|
||||
'clawsec.prompt.security',
|
||||
'prompt.security',
|
||||
'raw.githubusercontent.com',
|
||||
'github.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom error class for security policy violations.
|
||||
* These errors should always propagate and never be silently caught.
|
||||
*/
|
||||
export class SecurityPolicyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecurityPolicyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
|
||||
*/
|
||||
function createSecureAgent(): https.Agent {
|
||||
return new https.Agent({
|
||||
// Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1)
|
||||
minVersion: 'TLSv1.2',
|
||||
// Ensure certificate validation is enabled (reject unauthorized certificates)
|
||||
rejectUnauthorized: true,
|
||||
// Use strong cipher suites
|
||||
ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL is from an allowed domain.
|
||||
*/
|
||||
function isAllowedDomain(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow HTTPS protocol
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Check if hostname matches any allowed domain
|
||||
return ALLOWED_DOMAINS.some(
|
||||
(allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure wrapper around fetch with TLS enforcement and domain validation.
|
||||
*/
|
||||
export async function secureFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
// Validate domain before making request
|
||||
if (!isAllowedDomain(url)) {
|
||||
throw new SecurityPolicyError(
|
||||
`Security policy violation: URL domain not allowed. ` +
|
||||
`Only connections to ${ALLOWED_DOMAINS.join(', ')} are permitted. ` +
|
||||
`Blocked: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
// Use secure HTTPS agent with TLS 1.2+ enforcement
|
||||
const agent = createSecureAgent();
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
// @ts-expect-error - agent is supported in Node.js fetch
|
||||
agent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a signature from various formats (base64 string or JSON).
|
||||
*/
|
||||
function decodeSignature(signatureRaw: string): Buffer | null {
|
||||
const trimmed = signatureRaw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let encoded = trimmed;
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && typeof parsed.signature === 'string') {
|
||||
encoded = parsed.signature;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = encoded.replace(/\s+/g, '');
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
return Buffer.from(normalized, 'base64');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an Ed25519 signature for a payload.
|
||||
*/
|
||||
export function verifySignedPayload(
|
||||
payloadRaw: string,
|
||||
signatureRaw: string,
|
||||
publicKeyPem: string
|
||||
): boolean {
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
if (!signature) return false;
|
||||
|
||||
const keyPem = publicKeyPem.trim();
|
||||
if (!keyPem) return false;
|
||||
|
||||
try {
|
||||
const publicKey = crypto.createPublicKey(keyPem);
|
||||
return crypto.verify(null, Buffer.from(payloadRaw, 'utf8'), publicKey, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of content.
|
||||
*/
|
||||
export function sha256Hex(content: string | Buffer): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of a file.
|
||||
* Convenience wrapper for file-based integrity monitoring and package verification.
|
||||
*/
|
||||
export function sha256File(filePath: string): string {
|
||||
const data = fs.readFileSync(filePath);
|
||||
return sha256Hex(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates an Ed25519 public key from PEM format.
|
||||
* @throws {SecurityPolicyError} if PEM format is invalid
|
||||
*/
|
||||
export function loadPublicKey(pemString: string): crypto.KeyObject {
|
||||
const trimmed = pemString.trim();
|
||||
if (!trimmed.startsWith('-----BEGIN PUBLIC KEY-----')) {
|
||||
throw new SecurityPolicyError('Invalid PEM format: must start with -----BEGIN PUBLIC KEY-----');
|
||||
}
|
||||
|
||||
try {
|
||||
return crypto.createPublicKey(trimmed);
|
||||
} catch (error) {
|
||||
throw new SecurityPolicyError(
|
||||
`Failed to load public key: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies Ed25519 detached signature for a file.
|
||||
* Matches the API of verify_detached_ed25519.mjs from OpenClaw.
|
||||
*
|
||||
* @param dataPath - Path to the file to verify
|
||||
* @param signaturePath - Path to the detached signature file (.sig)
|
||||
* @param publicKeyPem - Ed25519 public key in PEM format
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export function verifyDetachedSignature(
|
||||
dataPath: string,
|
||||
signaturePath: string,
|
||||
publicKeyPem: string
|
||||
): boolean {
|
||||
try {
|
||||
const data = fs.readFileSync(dataPath);
|
||||
const signatureRaw = fs.readFileSync(signaturePath, 'utf8');
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
|
||||
if (!signature) return false;
|
||||
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem.trim());
|
||||
return crypto.verify(null, data, publicKey, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies detached signature with detailed error information.
|
||||
* Useful for debugging signature verification failures.
|
||||
*
|
||||
* @param dataPath - Path to the file to verify
|
||||
* @param signaturePath - Path to the detached signature file (.sig)
|
||||
* @param publicKeyPem - Ed25519 public key in PEM format
|
||||
* @returns Object with valid flag and optional error message
|
||||
*/
|
||||
export function verifyDetachedSignatureWithDetails(
|
||||
dataPath: string,
|
||||
signaturePath: string,
|
||||
publicKeyPem: string
|
||||
): { valid: boolean; error?: string } {
|
||||
try {
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
return { valid: false, error: 'Data file not found' };
|
||||
}
|
||||
if (!fs.existsSync(signaturePath)) {
|
||||
return { valid: false, error: 'Signature file not found' };
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(dataPath);
|
||||
const signatureRaw = fs.readFileSync(signaturePath, 'utf8');
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
|
||||
if (!signature) {
|
||||
return { valid: false, error: 'Invalid signature format' };
|
||||
}
|
||||
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem.trim());
|
||||
const valid = crypto.verify(null, data, publicKey, signature);
|
||||
|
||||
return { valid, error: valid ? undefined : 'Signature verification failed' };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Verification error: ${error instanceof Error ? error.message : String(error)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies multiple files against expected hashes.
|
||||
* Returns list of files that don't match their expected hashes.
|
||||
*
|
||||
* @param files - Map of file paths to expected SHA-256 hashes
|
||||
* @returns Array of mismatches with path, expected, and actual hashes
|
||||
*/
|
||||
export function verifyFileHashes(
|
||||
files: Record<string, string>
|
||||
): { path: string; expected: string; actual: string }[] {
|
||||
const mismatches = [];
|
||||
|
||||
for (const [path, expectedHash] of Object.entries(files)) {
|
||||
try {
|
||||
const actualHash = sha256File(path);
|
||||
if (actualHash !== expectedHash) {
|
||||
mismatches.push({ path, expected: expectedHash, actual: actualHash });
|
||||
}
|
||||
} catch (error) {
|
||||
// File missing or unreadable
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: expectedHash,
|
||||
actual: `ERROR: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts SHA-256 value from various formats.
|
||||
*/
|
||||
function extractSha256Value(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && 'sha256' in value) {
|
||||
const sha256 = (value as { sha256: unknown }).sha256;
|
||||
if (typeof sha256 === 'string') {
|
||||
const normalized = sha256.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a checksums manifest JSON.
|
||||
*/
|
||||
export function parseChecksumsManifest(manifestRaw: string): ChecksumsManifest {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(manifestRaw);
|
||||
} catch {
|
||||
throw new Error('Checksum manifest is not valid JSON');
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('Checksum manifest must be an object');
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
const algorithmRaw = typeof obj.algorithm === 'string' ? obj.algorithm.trim().toLowerCase() : 'sha256';
|
||||
if (algorithmRaw !== 'sha256') {
|
||||
throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || '(empty)'}`);
|
||||
}
|
||||
|
||||
// Support legacy manifest formats
|
||||
const schemaVersion = (
|
||||
typeof obj.schema_version === 'string' ? obj.schema_version.trim() :
|
||||
typeof obj.version === 'string' ? obj.version.trim() :
|
||||
typeof obj.generated_at === 'string' ? obj.generated_at.trim() :
|
||||
'1'
|
||||
);
|
||||
|
||||
if (!schemaVersion) {
|
||||
throw new Error('Checksum manifest missing schema_version');
|
||||
}
|
||||
|
||||
if (typeof obj.files !== 'object' || obj.files === null) {
|
||||
throw new Error('Checksum manifest missing files object');
|
||||
}
|
||||
|
||||
const files: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj.files)) {
|
||||
if (!key.trim()) continue;
|
||||
const digest = extractSha256Value(value);
|
||||
if (!digest) {
|
||||
throw new Error(`Invalid checksum digest entry for ${key}`);
|
||||
}
|
||||
files[key] = digest;
|
||||
}
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
throw new Error('Checksum manifest has no usable file digests');
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: schemaVersion,
|
||||
algorithm: 'sha256',
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a checksum entry name for matching.
|
||||
*/
|
||||
function normalizeChecksumEntryName(entryName: string): string {
|
||||
return entryName
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^(?:\.\/)+/, '')
|
||||
.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a checksum manifest entry by name.
|
||||
*/
|
||||
function resolveChecksumManifestEntry(
|
||||
files: Record<string, string>,
|
||||
entryName: string
|
||||
): { key: string; digest: string } | null {
|
||||
const normalizedEntry = normalizeChecksumEntryName(entryName);
|
||||
if (!normalizedEntry) return null;
|
||||
|
||||
// Try direct match and common variations
|
||||
const directCandidates = [
|
||||
normalizedEntry,
|
||||
normalizedEntry.split('/').pop() || '',
|
||||
`advisories/${normalizedEntry.split('/').pop() || ''}`,
|
||||
].filter((c, i, a) => c && a.indexOf(c) === i);
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (candidate in files) {
|
||||
return { key: candidate, digest: files[candidate] };
|
||||
}
|
||||
}
|
||||
|
||||
// Try basename matching
|
||||
const basename = normalizedEntry.split('/').pop() || '';
|
||||
if (!basename) return null;
|
||||
|
||||
const basenameMatches = Object.entries(files).filter(([key]) => {
|
||||
const normalizedKey = normalizeChecksumEntryName(key);
|
||||
return normalizedKey.split('/').pop() === basename;
|
||||
});
|
||||
|
||||
if (basenameMatches.length > 1) {
|
||||
throw new Error(
|
||||
`Checksum manifest entry is ambiguous for ${entryName}; ` +
|
||||
`multiple manifest keys share basename ${basename}`
|
||||
);
|
||||
}
|
||||
|
||||
if (basenameMatches.length === 1) {
|
||||
const [resolvedKey, digest] = basenameMatches[0];
|
||||
return { key: resolvedKey, digest };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies checksums for expected entries.
|
||||
*/
|
||||
export function verifyChecksums(
|
||||
manifest: ChecksumsManifest,
|
||||
expectedEntries: Record<string, string | Buffer>
|
||||
): void {
|
||||
for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
|
||||
if (!entryName) continue;
|
||||
|
||||
const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
|
||||
if (!resolved) {
|
||||
throw new Error(`Checksum manifest missing required entry: ${entryName}`);
|
||||
}
|
||||
|
||||
const actualDigest = sha256Hex(entryContent);
|
||||
if (actualDigest !== resolved.digest) {
|
||||
throw new Error(`Checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches text from a URL with timeout.
|
||||
*/
|
||||
export async function fetchText(url: string, timeoutMs: number = 10000): Promise<string | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await secureFetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json,text/plain;q=0.9,*/*;q=0.8' },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
// Re-throw security policy violations - these should never be silently caught
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
throw error;
|
||||
}
|
||||
// Network errors, timeouts, etc. return null (graceful degradation)
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default checksums URL from feed URL.
|
||||
*/
|
||||
export function defaultChecksumsUrl(feedUrl: string): string {
|
||||
try {
|
||||
return new URL('checksums.json', feedUrl).toString();
|
||||
} catch {
|
||||
const fallbackBase = feedUrl.replace(/\/?[^/]*$/, '');
|
||||
return `${fallbackBase}/checksums.json`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts the basename from a URL or file path.
|
||||
*/
|
||||
function _safeBasename(urlOrPath: string, fallback: string): string {
|
||||
try {
|
||||
const parsed = new URL(urlOrPath);
|
||||
const pathname = parsed.pathname;
|
||||
const lastSlash = pathname.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < pathname.length - 1) {
|
||||
return pathname.slice(lastSlash + 1);
|
||||
}
|
||||
} catch {
|
||||
const normalized = urlOrPath.trim();
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < normalized.length - 1) {
|
||||
return normalized.slice(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* TypeScript types for NanoClaw Skill Installer
|
||||
* Adapted from ClawSec's guarded skill installer
|
||||
*/
|
||||
|
||||
export interface Advisory {
|
||||
id: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
type: 'vulnerable_skill' | 'malicious_skill' | 'prompt_injection' | string;
|
||||
title: string;
|
||||
description: string;
|
||||
affected: string[]; // e.g., ["skill-name@1.0.0", "skill-name@1.0.1"]
|
||||
action: string;
|
||||
published: string;
|
||||
references: string[];
|
||||
cvss_score?: number;
|
||||
nvd_url?: string;
|
||||
source?: string;
|
||||
github_issue_url?: string;
|
||||
reporter?: {
|
||||
agent_name?: string;
|
||||
opener_type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdvisoryFeed {
|
||||
version: string;
|
||||
updated: string;
|
||||
description: string;
|
||||
advisories: Advisory[];
|
||||
}
|
||||
|
||||
export interface AdvisoryMatch {
|
||||
advisory: Advisory;
|
||||
matchedSpecifier: string;
|
||||
isHighRisk: boolean;
|
||||
}
|
||||
|
||||
export interface ReputationResult {
|
||||
score: number; // 0-100
|
||||
warnings: string[];
|
||||
virusTotalFlags: string[];
|
||||
safe: boolean;
|
||||
}
|
||||
|
||||
export interface SkillMetadata {
|
||||
slug: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
downloads: number;
|
||||
}
|
||||
|
||||
export interface InspectSkillResult {
|
||||
skill: SkillMetadata;
|
||||
reputation: ReputationResult;
|
||||
advisories: AdvisoryMatch[];
|
||||
overallStatus: 'safe' | 'reputation_warning' | 'advisory_warning' | 'blocked';
|
||||
}
|
||||
|
||||
export interface SkillInstallRequest {
|
||||
request_id: string;
|
||||
user_jid: string;
|
||||
group_jid: string;
|
||||
skill_slug: string;
|
||||
skill_version: string | null;
|
||||
reputation_score: number;
|
||||
reputation_warnings: string[];
|
||||
advisories: AdvisoryMatch[];
|
||||
created_at: number; // Unix timestamp
|
||||
expires_at: number; // Unix timestamp
|
||||
status: 'pending' | 'confirmed' | 'expired' | 'cancelled';
|
||||
confirmed_at: number | null;
|
||||
}
|
||||
|
||||
export interface ChecksumsManifest {
|
||||
schema_version: string;
|
||||
algorithm: 'sha256';
|
||||
files: Record<string, string>; // filename -> hex digest
|
||||
}
|
||||
|
||||
export interface SignatureVerificationOptions {
|
||||
signatureUrl?: string;
|
||||
checksumsUrl?: string;
|
||||
checksumsSignatureUrl?: string;
|
||||
publicKeyPem: string;
|
||||
checksumsPublicKeyPem?: string;
|
||||
allowUnsigned?: boolean;
|
||||
verifyChecksumManifest?: boolean;
|
||||
}
|
||||
|
||||
export interface AffectedSpecifier {
|
||||
name: string;
|
||||
versionSpec: string; // e.g., "1.0.0", "^1.0.0", "*"
|
||||
}
|
||||
|
||||
// MCP Tool Request/Response Types
|
||||
|
||||
export interface InspectSkillRequest {
|
||||
slug: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RequestSkillInstallRequest {
|
||||
slug: string;
|
||||
version?: string;
|
||||
target_group_jid?: string;
|
||||
}
|
||||
|
||||
export interface RequestSkillInstallResponse {
|
||||
request_id: string;
|
||||
status: 'safe' | 'reputation_warning' | 'advisory_warning' | 'blocked';
|
||||
reputation?: ReputationResult;
|
||||
advisories?: AdvisoryMatch[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ConfirmSkillInstallRequest {
|
||||
request_id: string;
|
||||
acknowledge_reputation?: boolean;
|
||||
acknowledge_advisories?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmSkillInstallResponse {
|
||||
status: 'installed' | 'failed';
|
||||
installed_path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ListSkillsRequest {
|
||||
target_group_jid?: string;
|
||||
}
|
||||
|
||||
export interface ListSkillsResponse {
|
||||
skills: Array<{
|
||||
slug: string;
|
||||
version: string;
|
||||
installed_at: string;
|
||||
path: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RemoveSkillRequest {
|
||||
slug: string;
|
||||
target_group_jid?: string;
|
||||
}
|
||||
|
||||
export interface RemoveSkillResponse {
|
||||
status: 'removed' | 'not_found';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// IPC Task Types
|
||||
|
||||
export interface IpcSkillInstallRequest {
|
||||
type: 'skill_install_request';
|
||||
slug: string;
|
||||
version?: string;
|
||||
target_group_jid?: string;
|
||||
user_jid: string;
|
||||
group_folder: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface IpcSkillInstallConfirm {
|
||||
type: 'skill_install_confirm';
|
||||
request_id: string;
|
||||
acknowledge_reputation: boolean;
|
||||
acknowledge_advisories: boolean;
|
||||
user_jid: string;
|
||||
group_folder: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface IpcSkillRemove {
|
||||
type: 'skill_remove';
|
||||
slug: string;
|
||||
target_group_jid?: string;
|
||||
user_jid: string;
|
||||
group_folder: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Database Schema
|
||||
|
||||
export interface SkillInstallRequestRow {
|
||||
request_id: string;
|
||||
user_jid: string;
|
||||
group_jid: string;
|
||||
skill_slug: string;
|
||||
skill_version: string | null;
|
||||
reputation_score: number;
|
||||
reputation_warnings_json: string; // JSON array
|
||||
advisories_json: string; // JSON array
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
status: 'pending' | 'confirmed' | 'expired' | 'cancelled';
|
||||
confirmed_at: number | null;
|
||||
}
|
||||
|
||||
export interface InstalledSkillRow {
|
||||
slug: string;
|
||||
version: string;
|
||||
installed_at: string;
|
||||
installed_by: string; // user_jid
|
||||
path: string;
|
||||
metadata_json: string; // SkillMetadata as JSON
|
||||
}
|
||||
|
||||
// Skill Signature Verification Types (Phase 1)
|
||||
|
||||
/**
|
||||
* IPC request for skill signature verification
|
||||
*/
|
||||
export interface VerifySkillSignatureRequest {
|
||||
type: 'verify_skill_signature';
|
||||
requestId: string;
|
||||
groupFolder: string;
|
||||
timestamp: string;
|
||||
packagePath: string;
|
||||
signaturePath: string;
|
||||
publicKeyPem?: string; // Optional: override default public key
|
||||
allowUnsigned?: boolean; // Optional: allow missing signature (default: false)
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC response for skill signature verification
|
||||
*/
|
||||
export interface VerifySkillSignatureResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: {
|
||||
valid: boolean;
|
||||
signer: string; // 'clawsec' or custom signer identifier
|
||||
packageHash: string; // SHA-256 of package
|
||||
verifiedAt: string; // ISO timestamp
|
||||
algorithm: 'Ed25519';
|
||||
};
|
||||
error?: {
|
||||
code: 'SIGNATURE_INVALID' | 'FILE_NOT_FOUND' | 'CRYPTO_ERROR' | 'SERVICE_UNAVAILABLE';
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP tool parameters for package verification
|
||||
*/
|
||||
export interface VerifySkillPackageParams {
|
||||
packagePath: string;
|
||||
signaturePath?: string; // Optional: auto-detects .sig if omitted
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* ClawSec Advisory Feed MCP Tools for NanoClaw
|
||||
*
|
||||
* Add these tools to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
*
|
||||
* These tools run in the container context and read from the host-managed
|
||||
* advisory cache at /workspace/project/data/clawsec-advisory-cache.json
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
// These variables are provided by the host environment (ipc-mcp-stdio.ts)
|
||||
// when this code is integrated into the NanoClaw container agent.
|
||||
declare const server: { tool: (...args: any[]) => void };
|
||||
declare function writeIpcFile(dir: string, data: any): void;
|
||||
declare const TASKS_DIR: string;
|
||||
declare const groupFolder: string;
|
||||
|
||||
// Add these helper functions to the file:
|
||||
|
||||
/**
|
||||
* Discover installed skills in a directory
|
||||
*/
|
||||
async function discoverInstalledSkills(installRoot: string): Promise<Array<{
|
||||
name: string;
|
||||
version: string | null;
|
||||
dirName: string;
|
||||
}>> {
|
||||
const skills: Array<{ name: string; version: string | null; dirName: string }> = [];
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(installRoot, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const skillJsonPath = path.join(installRoot, entry.name, 'skill.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(skillJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
skills.push({
|
||||
name: parsed.name || entry.name,
|
||||
version: parsed.version || null,
|
||||
dirName: entry.name,
|
||||
});
|
||||
} catch {
|
||||
// Skill without skill.json, use directory name
|
||||
skills.push({
|
||||
name: entry.name,
|
||||
version: null,
|
||||
dirName: entry.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Return empty if directory doesn't exist
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find advisory matches for installed skills
|
||||
*/
|
||||
function findAdvisoryMatches(
|
||||
advisories: any[],
|
||||
skills: Array<{ name: string; version: string | null; dirName: string }>
|
||||
): Array<{
|
||||
advisory: any;
|
||||
skill: { name: string; version: string | null; dirName: string };
|
||||
matchedAffected: string[];
|
||||
}> {
|
||||
const matches: Array<{
|
||||
advisory: any;
|
||||
skill: { name: string; version: string | null; dirName: string };
|
||||
matchedAffected: string[];
|
||||
}> = [];
|
||||
|
||||
for (const advisory of advisories) {
|
||||
for (const skill of skills) {
|
||||
const matchedAffected: string[] = [];
|
||||
|
||||
for (const affected of advisory.affected || []) {
|
||||
const atIndex = affected.lastIndexOf('@');
|
||||
const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected;
|
||||
|
||||
if (affectedName === skill.name || affectedName === skill.dirName) {
|
||||
matchedAffected.push(affected);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedAffected.length > 0) {
|
||||
matches.push({ advisory, skill, matchedAffected });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Add these tools to the server:
|
||||
|
||||
server.tool(
|
||||
'clawsec_check_advisories',
|
||||
'Check ClawSec advisory feed for security issues affecting installed skills. Returns list of matching advisories with details. Use this to scan for known vulnerabilities, malicious skills, or deprecated packages.',
|
||||
{
|
||||
installRoot: z.string().optional().describe('Skills installation directory (default: ~/.claude/skills)'),
|
||||
forceRefresh: z.boolean().optional().describe('Force cache refresh before checking (causes 1-2 second delay)'),
|
||||
},
|
||||
async (args) => {
|
||||
// Request cache refresh if needed
|
||||
if (args.forceRefresh) {
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'refresh_advisory_cache',
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// Wait for refresh (async, best-effort)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
// Read cache from shared mount
|
||||
const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json';
|
||||
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
const installRoot = args.installRoot || path.join(process.env.HOME || '~', '.claude', 'skills');
|
||||
|
||||
// Discover installed skills
|
||||
const skills = await discoverInstalledSkills(installRoot);
|
||||
|
||||
// Find matches
|
||||
const matches = findAdvisoryMatches(cacheData.feed.advisories, skills);
|
||||
|
||||
// Calculate cache age
|
||||
const cacheAge = Date.now() - Date.parse(cacheData.fetchedAt);
|
||||
const cacheAgeMinutes = Math.floor(cacheAge / 60000);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
feedUpdated: cacheData.feed.updated || null,
|
||||
totalAdvisories: cacheData.feed.advisories.length,
|
||||
installedSkills: skills.length,
|
||||
matches: matches.map(m => ({
|
||||
advisory: {
|
||||
id: m.advisory.id,
|
||||
severity: m.advisory.severity,
|
||||
type: m.advisory.type,
|
||||
title: m.advisory.title,
|
||||
description: m.advisory.description,
|
||||
action: m.advisory.action,
|
||||
published: m.advisory.published,
|
||||
},
|
||||
skill: m.skill,
|
||||
matchedAffected: m.matchedAffected,
|
||||
})),
|
||||
cacheAge: `${cacheAgeMinutes} minutes`,
|
||||
cacheTimestamp: cacheData.fetchedAt,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to check advisories: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'clawsec_check_skill_safety',
|
||||
'Check if a specific skill is safe to install based on ClawSec advisory feed. Returns safety recommendation (install/block/review) with reasons. Use this as a pre-install gate before installing any skill.',
|
||||
{
|
||||
skillName: z.string().describe('Name of skill to check'),
|
||||
skillVersion: z.string().optional().describe('Version of skill (optional, for version-specific checks)'),
|
||||
},
|
||||
async (args) => {
|
||||
const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json';
|
||||
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
|
||||
// Find matching advisories for this skill
|
||||
const matchingAdvisories = cacheData.feed.advisories.filter((advisory: any) =>
|
||||
advisory.affected.some((affected: string) => {
|
||||
const atIndex = affected.lastIndexOf('@');
|
||||
const affectedName = atIndex > 0 ? affected.slice(0, atIndex) : affected;
|
||||
return affectedName === args.skillName;
|
||||
})
|
||||
);
|
||||
|
||||
if (matchingAdvisories.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
safe: true,
|
||||
advisories: [],
|
||||
recommendation: 'install',
|
||||
reason: 'No known advisories for this skill',
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Evaluate severity
|
||||
const hasMalicious = matchingAdvisories.some((a: any) => a.type === 'malicious');
|
||||
const hasRemoveAction = matchingAdvisories.some((a: any) => a.action === 'remove');
|
||||
const hasCritical = matchingAdvisories.some((a: any) => a.severity === 'critical');
|
||||
const hasHigh = matchingAdvisories.some((a: any) => a.severity === 'high');
|
||||
|
||||
let recommendation: 'install' | 'block' | 'review';
|
||||
let reason: string;
|
||||
|
||||
if (hasMalicious || hasRemoveAction) {
|
||||
recommendation = 'block';
|
||||
reason = 'Malicious skill or removal recommended by ClawSec';
|
||||
} else if (hasCritical) {
|
||||
recommendation = 'block';
|
||||
reason = 'Critical security advisory - do not install';
|
||||
} else if (hasHigh) {
|
||||
recommendation = 'review';
|
||||
reason = 'High severity advisory - user review strongly recommended';
|
||||
} else {
|
||||
recommendation = 'review';
|
||||
reason = 'Advisory found - review details before installing';
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
safe: false, // Always false when advisories exist
|
||||
advisories: matchingAdvisories.map((a: any) => ({
|
||||
id: a.id,
|
||||
severity: a.severity,
|
||||
type: a.type,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
action: a.action,
|
||||
published: a.published,
|
||||
affected: a.affected,
|
||||
})),
|
||||
recommendation,
|
||||
reason,
|
||||
skillName: args.skillName,
|
||||
advisoryCount: matchingAdvisories.length,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
// Conservative: block on error
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
safe: false,
|
||||
advisories: [],
|
||||
recommendation: 'review',
|
||||
reason: `Failed to verify safety: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error: true,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'clawsec_list_advisories',
|
||||
'List ClawSec advisories with optional filtering. Use this to browse security advisories, filter by severity/type, or search for specific affected skills.',
|
||||
{
|
||||
severity: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('Filter by severity level'),
|
||||
type: z.enum(['vulnerability', 'malicious', 'deprecated']).optional().describe('Filter by advisory type'),
|
||||
affectedSkill: z.string().optional().describe('Filter by affected skill name (partial match supported)'),
|
||||
limit: z.number().optional().describe('Maximum number of results (default: unlimited)'),
|
||||
},
|
||||
async (args) => {
|
||||
const cacheFile = '/workspace/project/data/clawsec-advisory-cache.json';
|
||||
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
let advisories = [...cacheData.feed.advisories];
|
||||
|
||||
// Apply filters
|
||||
if (args.severity) {
|
||||
advisories = advisories.filter((a: any) => a.severity === args.severity);
|
||||
}
|
||||
if (args.type) {
|
||||
advisories = advisories.filter((a: any) => a.type === args.type);
|
||||
}
|
||||
if (args.affectedSkill) {
|
||||
advisories = advisories.filter((a: any) =>
|
||||
a.affected.some((spec: string) => spec.includes(args.affectedSkill!))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by severity (critical first) and published date (newest first)
|
||||
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
advisories.sort((a: any, b: any) => {
|
||||
const severityDiff = (severityOrder[a.severity] || 999) - (severityOrder[b.severity] || 999);
|
||||
if (severityDiff !== 0) return severityDiff;
|
||||
return (b.published || '').localeCompare(a.published || '');
|
||||
});
|
||||
|
||||
// Apply limit
|
||||
const originalCount = advisories.length;
|
||||
if (args.limit && args.limit > 0) {
|
||||
advisories = advisories.slice(0, args.limit);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
feedUpdated: cacheData.feed.updated || null,
|
||||
advisories: advisories.map((a: any) => ({
|
||||
id: a.id,
|
||||
severity: a.severity,
|
||||
type: a.type,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
action: a.action,
|
||||
published: a.published,
|
||||
affected: a.affected,
|
||||
})),
|
||||
total: cacheData.feed.advisories.length,
|
||||
filtered: originalCount,
|
||||
returned: advisories.length,
|
||||
filters: {
|
||||
severity: args.severity || null,
|
||||
type: args.type || null,
|
||||
affectedSkill: args.affectedSkill || null,
|
||||
limit: args.limit || null,
|
||||
},
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to list advisories: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'clawsec_refresh_cache',
|
||||
'Request immediate refresh of the advisory cache from ClawSec feed. This fetches the latest advisories and verifies signatures. Use when you need up-to-date advisory information.',
|
||||
{},
|
||||
async () => {
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'refresh_advisory_cache',
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: 'Advisory cache refresh requested. This may take a few seconds. Check status with clawsec_check_advisories.',
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* ClawSec File Integrity Monitoring MCP Tools for NanoClaw
|
||||
*
|
||||
* Add these tools to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
*
|
||||
* These tools run in the container context and communicate with the host-side
|
||||
* integrity monitor via IPC.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
// These variables are provided by the host environment (ipc-mcp-stdio.ts)
|
||||
// when this code is integrated into the NanoClaw container agent.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare const server: { tool: (...args: any[]) => void };
|
||||
declare function writeIpcFile(dir: string, data: any): void;
|
||||
declare const TASKS_DIR: string;
|
||||
declare const groupFolder: string;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// Result waiting helper
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function waitForResult(requestId: string, timeoutMs: number = 60000): Promise<any> {
|
||||
const resultDir = '/workspace/ipc/clawsec_results';
|
||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
||||
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (fs.existsSync(resultPath)) {
|
||||
const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
|
||||
fs.unlinkSync(resultPath); // Cleanup
|
||||
return result;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Poll every 1s
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for result: ${requestId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool 1: clawsec_check_integrity
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_check_integrity',
|
||||
'Check protected files for unauthorized changes (drift). Automatically restores critical files to approved baselines. Use this for scheduled integrity monitoring or manual security checks.',
|
||||
{
|
||||
mode: z.enum(['check', 'status']).optional().describe('check=detect drift and restore, status=view baselines only (default: check)'),
|
||||
autoRestore: z.boolean().optional().describe('Auto-restore files in restore mode (default: true)'),
|
||||
},
|
||||
async (args) => {
|
||||
const requestId = `integrity-check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Write IPC request
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'integrity_check',
|
||||
requestId,
|
||||
mode: args.mode || 'check',
|
||||
autoRestore: args.autoRestore !== false,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for result
|
||||
const result = await waitForResult(requestId, 60000);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
isError: !result.success
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Integrity check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool 2: clawsec_approve_change
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_approve_change',
|
||||
'Approve an intentional file modification as the new approved baseline. Use this after making legitimate changes to protected files (e.g., updating CLAUDE.md or registered_groups.json).',
|
||||
{
|
||||
path: z.string().describe('Absolute path to file to approve (e.g., /workspace/group/CLAUDE.md)'),
|
||||
note: z.string().optional().describe('Optional note explaining why this change is being approved'),
|
||||
},
|
||||
async (args) => {
|
||||
const requestId = `integrity-approve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Write IPC request
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'integrity_approve',
|
||||
requestId,
|
||||
path: args.path,
|
||||
note: args.note || '',
|
||||
approvedBy: 'agent', // In production, should be user JID
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForResult(requestId, 30000);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
isError: !result.success
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Approve failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool 3: clawsec_integrity_status
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_integrity_status',
|
||||
'View current baseline status for protected files without checking for drift. Use this to see what files are monitored, when baselines were created, and their current hashes.',
|
||||
{
|
||||
path: z.string().optional().describe('Optional: specific file path to check. If omitted, shows all protected files.'),
|
||||
},
|
||||
async (args) => {
|
||||
const requestId = `integrity-status-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'integrity_status',
|
||||
requestId,
|
||||
path: args.path,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForResult(requestId, 30000);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
isError: !result.success
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Status check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool 4: clawsec_verify_audit
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_verify_audit',
|
||||
'Verify the integrity of the audit log hash chain. Use this to detect if the audit log has been tampered with. A valid chain proves all logged events are authentic.',
|
||||
{},
|
||||
async () => {
|
||||
const requestId = `integrity-verify-audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'integrity_verify_audit',
|
||||
requestId,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForResult(requestId, 30000);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
isError: !result.success
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Audit verification failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Usage Examples (for documentation)
|
||||
// ============================================================================
|
||||
|
||||
// Usage Examples (for documentation):
|
||||
//
|
||||
// Example 1: Scheduled Integrity Check
|
||||
//
|
||||
// schedule_task({
|
||||
// prompt: 'Check file integrity with clawsec_check_integrity...',
|
||||
// schedule_type: 'cron',
|
||||
// schedule_value: '0,30 * * * *', // Every 30 minutes
|
||||
// context_mode: 'isolated'
|
||||
// });
|
||||
//
|
||||
// Example 2: Pre-Deployment Check
|
||||
//
|
||||
// const check = await tools.clawsec_check_integrity({ mode: 'check', autoRestore: false });
|
||||
// if (check.drift_detected) { ... }
|
||||
//
|
||||
// Example 3: Approve Legitimate Changes
|
||||
//
|
||||
// await tools.clawsec_approve_change({
|
||||
// path: '/workspace/group/CLAUDE.md',
|
||||
// note: 'Updated agent instructions to include new skill'
|
||||
// });
|
||||
//
|
||||
// Example 4: Audit Verification
|
||||
//
|
||||
// const audit = await tools.clawsec_verify_audit();
|
||||
// if (!audit.valid) { ... }
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* ClawSec Skill Signature Verification MCP Tool for NanoClaw
|
||||
*
|
||||
* Add this tool to /workspace/project/container/agent-runner/src/ipc-mcp-stdio.ts
|
||||
*
|
||||
* This tool verifies Ed25519 signatures on skill packages to prevent supply chain attacks.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
// These variables are provided by the host environment (ipc-mcp-stdio.ts)
|
||||
// when this code is integrated into the NanoClaw container agent.
|
||||
declare const server: { tool: (...args: any[]) => void };
|
||||
declare function writeIpcFile(dir: string, data: any): void;
|
||||
declare const TASKS_DIR: string;
|
||||
declare const groupFolder: string;
|
||||
|
||||
// Result waiting helper
|
||||
async function waitForResult(requestId: string, timeoutMs: number = 5000): Promise<any> {
|
||||
const resultDir = '/workspace/ipc/clawsec_results';
|
||||
const resultPath = path.join(resultDir, `${requestId}.json`);
|
||||
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (fs.existsSync(resultPath)) {
|
||||
const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
|
||||
fs.unlinkSync(resultPath); // Cleanup
|
||||
return result;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // Poll every 100ms
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for result: ${requestId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool: clawsec_verify_skill_package
|
||||
// ============================================================================
|
||||
|
||||
server.tool(
|
||||
'clawsec_verify_skill_package',
|
||||
'Verify Ed25519 signature of a skill package before installation. Prevents installation of tampered or malicious skill packages by checking ClawSec signatures.',
|
||||
{
|
||||
packagePath: z.string().describe('Absolute path to skill package (.tar.gz or .zip)'),
|
||||
signaturePath: z.string().optional().describe('Path to signature file. If omitted, auto-detects <packagePath>.sig'),
|
||||
},
|
||||
async (args: { packagePath: string; signaturePath?: string }) => {
|
||||
const requestId = `verify-signature-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const sigPath = args.signaturePath || `${args.packagePath}.sig`;
|
||||
|
||||
// Validate package file exists
|
||||
if (!fs.existsSync(args.packagePath)) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
valid: false,
|
||||
recommendation: 'block',
|
||||
error: `Package file not found: ${args.packagePath}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Write IPC request to host
|
||||
writeIpcFile(TASKS_DIR, {
|
||||
type: 'verify_skill_signature',
|
||||
requestId,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
packagePath: args.packagePath,
|
||||
signaturePath: sigPath,
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for host to verify (5 second timeout)
|
||||
const result = await waitForResult(requestId, 5000);
|
||||
|
||||
if (!result.success) {
|
||||
// Service error or file not found
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
valid: false,
|
||||
recommendation: 'block',
|
||||
packagePath: args.packagePath,
|
||||
signaturePath: sigPath,
|
||||
error: result.message || 'Verification failed',
|
||||
reason: result.error?.code || 'UNKNOWN_ERROR'
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if signature is valid
|
||||
if (!result.data?.valid) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
valid: false,
|
||||
recommendation: 'block',
|
||||
packagePath: args.packagePath,
|
||||
signaturePath: sigPath,
|
||||
reason: result.data?.error || 'Signature verification failed',
|
||||
packageInfo: {
|
||||
sha256: result.data?.packageHash || 'unknown'
|
||||
}
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Signature valid!
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
valid: true,
|
||||
recommendation: 'install',
|
||||
packagePath: args.packagePath,
|
||||
signaturePath: sigPath,
|
||||
signer: result.data.signer,
|
||||
algorithm: result.data.algorithm,
|
||||
verifiedAt: result.data.verifiedAt,
|
||||
packageInfo: {
|
||||
size: fs.statSync(args.packagePath).size,
|
||||
sha256: result.data.packageHash
|
||||
}
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
valid: false,
|
||||
recommendation: 'block',
|
||||
error: `Verification timeout or error: ${error instanceof Error ? error.message : String(error)}`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"name": "clawsec-nanoclaw",
|
||||
"version": "0.0.1",
|
||||
"description": "ClawSec security suite for NanoClaw - Advisory feed monitoring, MCP tools for vulnerability checking, and Ed25519 signature verification for containerized WhatsApp bot agents",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
"keywords": [
|
||||
"security",
|
||||
"nanoclaw",
|
||||
"whatsapp-bot",
|
||||
"mcp-tools",
|
||||
"advisory",
|
||||
"feed",
|
||||
"threat-intel",
|
||||
"containers",
|
||||
"signature-verification",
|
||||
"vulnerability-scanning",
|
||||
"agents",
|
||||
"ai"
|
||||
],
|
||||
"platform": "nanoclaw",
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "NanoClaw skill documentation"
|
||||
},
|
||||
{
|
||||
"path": "INSTALL.md",
|
||||
"required": true,
|
||||
"description": "Installation guide for NanoClaw deployments"
|
||||
},
|
||||
{
|
||||
"path": "mcp-tools/advisory-tools.ts",
|
||||
"required": true,
|
||||
"description": "MCP tools for advisory checking in container context"
|
||||
},
|
||||
{
|
||||
"path": "host-services/advisory-cache.ts",
|
||||
"required": true,
|
||||
"description": "Host-side advisory cache manager with periodic feed fetching"
|
||||
},
|
||||
{
|
||||
"path": "host-services/ipc-handlers.ts",
|
||||
"required": true,
|
||||
"description": "IPC handlers for MCP tool requests"
|
||||
},
|
||||
{
|
||||
"path": "lib/signatures.ts",
|
||||
"required": true,
|
||||
"description": "Ed25519 signature verification utilities"
|
||||
},
|
||||
{
|
||||
"path": "lib/advisories.ts",
|
||||
"required": true,
|
||||
"description": "Advisory matching and vulnerability detection"
|
||||
},
|
||||
{
|
||||
"path": "lib/types.ts",
|
||||
"required": true,
|
||||
"description": "TypeScript type definitions"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed-signing-public.pem",
|
||||
"required": true,
|
||||
"description": "Pinned Ed25519 public key for feed signature verification"
|
||||
},
|
||||
{
|
||||
"path": "mcp-tools/signature-verification.ts",
|
||||
"required": true,
|
||||
"description": "Phase 1: MCP tool for skill package signature verification"
|
||||
},
|
||||
{
|
||||
"path": "host-services/skill-signature-handler.ts",
|
||||
"required": true,
|
||||
"description": "Phase 1: Host-side signature verification service"
|
||||
},
|
||||
{
|
||||
"path": "docs/SKILL_SIGNING.md",
|
||||
"required": true,
|
||||
"description": "Phase 1: Documentation for skill signing and verification"
|
||||
},
|
||||
{
|
||||
"path": "mcp-tools/integrity-tools.ts",
|
||||
"required": true,
|
||||
"description": "Phase 2: MCP tools for file integrity monitoring"
|
||||
},
|
||||
{
|
||||
"path": "host-services/integrity-handler.ts",
|
||||
"required": true,
|
||||
"description": "Phase 2: Host-side integrity monitoring service"
|
||||
},
|
||||
{
|
||||
"path": "guardian/integrity-monitor.ts",
|
||||
"required": true,
|
||||
"description": "Phase 2: Core file integrity monitoring engine"
|
||||
},
|
||||
{
|
||||
"path": "guardian/policy.json",
|
||||
"required": true,
|
||||
"description": "Phase 2: NanoClaw-specific file protection policy"
|
||||
},
|
||||
{
|
||||
"path": "docs/INTEGRITY.md",
|
||||
"required": true,
|
||||
"description": "Phase 2: Documentation for file integrity monitoring"
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"Advisory feed monitoring from clawsec.prompt.security",
|
||||
"MCP tools for agent-initiated vulnerability scans",
|
||||
"Pre-installation skill safety checks",
|
||||
"Ed25519 signature verification for advisory feeds",
|
||||
"Platform-specific advisory filtering (nanoclaw vs openclaw)",
|
||||
"Containerized agent support with IPC communication"
|
||||
],
|
||||
"nanoclaw": {
|
||||
"mcp_tools": [
|
||||
"clawsec_check_advisories",
|
||||
"clawsec_check_skill_safety",
|
||||
"clawsec_list_advisories",
|
||||
"clawsec_refresh_cache",
|
||||
"clawsec_verify_skill_package",
|
||||
"clawsec_check_integrity",
|
||||
"clawsec_approve_change",
|
||||
"clawsec_integrity_status",
|
||||
"clawsec_verify_audit"
|
||||
],
|
||||
"requires": {
|
||||
"node": ">=18.0.0",
|
||||
"nanoclaw": ">=0.1.0"
|
||||
},
|
||||
"integration": {
|
||||
"mcp_tools_file": "container/agent-runner/src/ipc-mcp-stdio.ts",
|
||||
"ipc_handlers_file": "host/ipc-handler.ts",
|
||||
"cache_location": "/workspace/project/data/clawsec-advisory-cache.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the ClawSec Suite will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.3]
|
||||
|
||||
### Added
|
||||
|
||||
- Contributor credit: portability and path-hardening improvements in this release were contributed by [@aldodelgado](https://github.com/aldodelgado) in PR #62.
|
||||
- Cross-shell path resolution support for home-directory tokens in suite path configuration (`~`, `$HOME`, `${HOME}`, `%USERPROFILE%`, `$env:HOME`).
|
||||
- Dedicated path-resolution regression coverage (`test/path_resolution.test.mjs`) including fallback behavior for invalid explicit path values.
|
||||
- Additional advisory/installer tests validating home-token expansion and escaped-token rejection.
|
||||
|
||||
### Changed
|
||||
|
||||
- Advisory guardian hook now resolves configured path environment variables through a shared portability helper.
|
||||
- Guarded install flow now resolves feed/signature/checksum/public-key path overrides through the same shared path helper for consistent behavior across shells/OSes.
|
||||
- Advisory matching now explicitly scopes to `application: "openclaw"` when present; legacy advisories without `application` remain eligible for backward compatibility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented advisory-check bypass when a single explicit path env var is malformed: invalid explicit values now fall back to safe defaults instead of aborting the entire hook run.
|
||||
|
||||
### Security
|
||||
|
||||
- Escaped/unexpanded home-token inputs in path config are explicitly rejected while preserving secure defaults.
|
||||
|
||||
## [0.1.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Advisory suppression module (`hooks/clawsec-advisory-guardian/lib/suppression.mjs`).
|
||||
- `loadAdvisorySuppression()` -- loads suppression config with `enabledFor: ["advisory"]` sentinel gate.
|
||||
- `isAdvisorySuppressed()` -- matches `advisory.id === rule.checkId` + case-insensitive skill name.
|
||||
- Advisory guardian handler integration: partitions matches into active/suppressed after `findMatches()`.
|
||||
- Suppressed matches tracked in state file (prevents re-evaluation) but not alerted.
|
||||
- Soft notification message for suppressed matches count.
|
||||
- Advisory suppression tests (13 tests in `advisory_suppression.test.mjs`).
|
||||
- Documentation in SKILL.md for advisory suppression/allowlist mechanism.
|
||||
|
||||
### Changed
|
||||
|
||||
- Advisory guardian handler (`handler.ts`) now loads suppression config and filters matches before alerting.
|
||||
|
||||
### Security
|
||||
|
||||
- Advisory suppression gated by config file sentinel (`enabledFor: ["advisory"]`) -- no CLI flag needed but config must explicitly opt in.
|
||||
- Suppressed matches are still tracked in state to maintain audit trail.
|
||||
|
||||
## [0.1.1] - 2026-02-16
|
||||
|
||||
### Added
|
||||
- Added `scripts/discover_skill_catalog.mjs` to dynamically discover installable skills from `https://clawsec.prompt.security/skills/index.json`.
|
||||
- Added `test/skill_catalog_discovery.test.mjs` to validate remote-catalog loading and fallback behavior.
|
||||
- Added CI signing-key drift guard script: `scripts/ci/verify_signing_key_consistency.sh`.
|
||||
|
||||
### Changed
|
||||
- Updated `SKILL.md` to use dynamic catalog discovery commands instead of hard-coded optional-skill names.
|
||||
- Updated advisory feed defaults to signed-host URL (`https://clawsec.prompt.security/advisories/feed.json`).
|
||||
- Improved checksum manifest key compatibility in feed verification logic (supports basename and `advisories/*` key formats).
|
||||
- Kept `openclaw-audit-watchdog` as a standalone skill (not embedded in `clawsec-suite`).
|
||||
|
||||
### Security
|
||||
- **Signing key drift control**: CI now enforces that all public key references (inline SKILL.md PEM, canonical `.pem` files, workflow-generated keys) resolve to the same fingerprint. Prevents stale, fabricated, or rotated-but-not-propagated key material from reaching releases.
|
||||
- Enforced in: `.github/workflows/skill-release.yml`, `.github/workflows/deploy-pages.yml`
|
||||
- Guard script: `scripts/ci/verify_signing_key_consistency.sh`
|
||||
|
||||
### Fixed
|
||||
- **Fixed fabricated signing key in SKILL.md**: The manual installation script contained a hallucinated Ed25519 public key and fingerprint (`35866e1b...`) that never corresponded to the actual release signing key. Replaced with the real public key derived from the GitHub-secret-held private key. The bogus key was introduced in v0.0.10 (`Integration/signing work #20`) and went undetected because no consistency check existed at the time.
|
||||
- Corrected `checksums.sig` naming in release verification documentation.
|
||||
|
||||
## [0.0.10] - 2026-02-11
|
||||
|
||||
### Security
|
||||
|
||||
#### Transport Security Hardening
|
||||
- **TLS Version Enforcement**: Eliminated support for TLS 1.0 and TLS 1.1, enforcing minimum TLS 1.2 for all HTTPS connections
|
||||
- **Certificate Validation**: Enabled strict certificate validation (`rejectUnauthorized: true`) to prevent MITM attacks
|
||||
- **Domain Allowlist**: Restricted advisory feed connections to approved domains only:
|
||||
- `clawsec.prompt.security` (official ClawSec feed host)
|
||||
- `prompt.security` (parent domain)
|
||||
- `raw.githubusercontent.com` (GitHub raw content)
|
||||
- `github.com` (GitHub releases)
|
||||
- **Strong Cipher Suites**: Configured modern cipher suites (AES-GCM, ChaCha20-Poly1305) for secure connections
|
||||
|
||||
#### Signature Verification & Checksum Validation
|
||||
- **Fixed unverified file publication**: Refactored `deploy-pages.yml` workflow to download release assets to temporary directory before signature verification, ensuring unverified files never reach public directory
|
||||
- **Fixed schema mismatch**: Updated `deploy-pages.yml` to generate `checksums.json` with proper `schema_version` and `algorithm` fields that match parser expectations
|
||||
- **Fixed missing checksums abort**: Updated `loadRemoteFeed` to gracefully skip checksum verification when `checksums.json` is missing (e.g., GitHub raw content), while still enforcing fail-closed signature verification
|
||||
- **Fixed parser strictness**: Enhanced `parseChecksumsManifest` to accept legacy manifest formats through a fallback chain:
|
||||
1. `schema_version` (new standard)
|
||||
2. `version` (skill-release.yml format)
|
||||
3. `generated_at` (old deploy-pages.yml format)
|
||||
4. `"1"` (ultimate fallback)
|
||||
|
||||
### Changed
|
||||
- Advisory feed loader now uses `secureFetch` wrapper with TLS 1.2+ enforcement and domain validation
|
||||
- Checksum verification is now graceful: feeds load successfully from sources without checksums (e.g., GitHub raw) while maintaining fail-closed signature verification
|
||||
- Workflow release mirroring flow changed from `download → verify → skip` to `download to temp → verify → mirror` (fail = delete temp)
|
||||
|
||||
### Fixed
|
||||
- Unverified skill releases no longer published to public directory on signature verification failure
|
||||
- Schema mismatch between generated and expected checksums manifest fields
|
||||
- Feed loading failures when checksums.json missing from upstream sources
|
||||
- Parser rejection of valid legacy manifest formats
|
||||
|
||||
### Security Impact
|
||||
- **Fail-closed security maintained**: All feed signatures still verified; invalid signatures reject feed loading
|
||||
- **No backward compatibility break**: Legacy manifests continue working through fallback chain
|
||||
- **Enhanced transport security**: Connections protected against downgrade attacks and MITM
|
||||
- **Defense in depth**: Multiple layers of verification (domain, TLS, certificate, signature, checksum)
|
||||
|
||||
---
|
||||
|
||||
## Release Notes Template
|
||||
|
||||
When creating a new release, copy this template to the GitHub release notes:
|
||||
|
||||
```markdown
|
||||
## Security Improvements
|
||||
|
||||
### Transport Security
|
||||
✅ TLS 1.2+ enforcement (eliminated TLS 1.0, 1.1)
|
||||
✅ Strict certificate validation
|
||||
✅ Domain allowlist (prompt.security, github.com only)
|
||||
✅ Modern cipher suites (AES-GCM, ChaCha20-Poly1305)
|
||||
|
||||
### Signature & Checksum Verification
|
||||
✅ Unverified files never published (temp directory workflow)
|
||||
✅ Proper schema fields in generated checksums.json
|
||||
✅ Graceful fallback when checksums missing (GitHub raw)
|
||||
✅ Legacy manifest format support (backward compatible)
|
||||
|
||||
### Testing
|
||||
All verification tests passed:
|
||||
- ✅ Unit tests: 14/14 passed
|
||||
- ✅ Parser lenience: 3/3 legacy formats accepted
|
||||
- ✅ Remote loading: Gracefully handles missing checksums
|
||||
- ✅ Workflow security: Temp directory prevents unverified publication
|
||||
```
|
||||
@@ -1,13 +1,12 @@
|
||||
# ClawSec Suite — Cron Check
|
||||
# ClawSec Suite — Heartbeat Check
|
||||
|
||||
Run this on a schedule (cron/systemd/CI/agent scheduler). It is written to be portable: it assumes only POSIX shell + curl + a SHA tool.
|
||||
Run this periodically (cron/systemd/CI/agent scheduler). It assumes POSIX shell, `curl`, and `jq`.
|
||||
|
||||
## Goals
|
||||
|
||||
1) Check whether ClawSec Suite has an update available
|
||||
2) Verify integrity of the installed suite package
|
||||
|
||||
> Design note: Uses the **checksums.json** file from the latest release, which contains version info and SHA256 hashes. Avoids reliance on a separate catalog manifest.
|
||||
1. Check whether `clawsec-suite` has an update available.
|
||||
2. Poll the advisory feed.
|
||||
3. Report new advisories, highlight affected installed skills, and require approval before removal actions.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,6 +16,9 @@ Run this on a schedule (cron/systemd/CI/agent scheduler). It is written to be po
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
SUITE_DIR="$INSTALL_ROOT/clawsec-suite"
|
||||
CHECKSUMS_URL="${CHECKSUMS_URL:-https://clawsec.prompt.security/releases/latest/download/checksums.json}"
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-https://clawsec.prompt.security/advisories/feed.json}"
|
||||
STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}"
|
||||
MIN_FEED_INTERVAL_SECONDS="${MIN_FEED_INTERVAL_SECONDS:-300}"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -29,100 +31,171 @@ set -euo pipefail
|
||||
test -d "$SUITE_DIR"
|
||||
test -f "$SUITE_DIR/skill.json"
|
||||
|
||||
echo "=== ClawSec update Check ==="
|
||||
echo "When: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Where: $SUITE_DIR"
|
||||
echo "=== ClawSec Suite Heartbeat ==="
|
||||
echo "When: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Suite: $SUITE_DIR"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Verify the currently installed suite files (local integrity)
|
||||
|
||||
This step is only meaningful if you ship a checksums file *inside* the suite directory (recommended).
|
||||
|
||||
If present, verify it:
|
||||
|
||||
```bash
|
||||
if [ -f "$SUITE_DIR/checksums.txt" ]; then
|
||||
echo "Verifying local checksums.txt"
|
||||
cd "$SUITE_DIR"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 -c checksums.txt
|
||||
else
|
||||
sha256sum -c checksums.txt
|
||||
fi
|
||||
else
|
||||
echo "NOTE: No local checksums.txt shipped; skipping local integrity verification"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1.5 — Verify Bundled Components
|
||||
|
||||
Check that bundled security skills are properly deployed:
|
||||
|
||||
```bash
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
SUITE_DIR="$INSTALL_ROOT/clawsec-suite"
|
||||
|
||||
# Function to check bundled skill
|
||||
check_bundled_skill() {
|
||||
local skill_name="$1"
|
||||
local skill_dir="$INSTALL_ROOT/$skill_name"
|
||||
local bundled_dir="$SUITE_DIR/bundled/$skill_name"
|
||||
|
||||
if [ -d "$skill_dir" ] && [ -f "$skill_dir/skill.json" ]; then
|
||||
SKILL_VERSION=$(jq -r '.version' "$skill_dir/skill.json")
|
||||
echo "✓ $skill_name v${SKILL_VERSION} is installed"
|
||||
elif [ -d "$bundled_dir" ] && [ -f "$bundled_dir/skill.json" ]; then
|
||||
echo "⚠ $skill_name bundled but not deployed"
|
||||
echo " Deploy with: cp -r '$bundled_dir' '$skill_dir'"
|
||||
else
|
||||
echo "✗ $skill_name not found"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Bundled Skills Status ==="
|
||||
check_bundled_skill "clawsec-feed"
|
||||
check_bundled_skill "openclaw-audit-watchdog"
|
||||
check_bundled_skill "soul-guardian"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Check for updates (using checksums.json)
|
||||
|
||||
Fetch the latest checksums.json from the release mirror. This file contains version info and SHA256 hashes for all release assets.
|
||||
## Step 1 — Check suite version updates
|
||||
|
||||
```bash
|
||||
TMP="$(mktemp -d)"
|
||||
cd "$TMP"
|
||||
|
||||
curl -fsSLo checksums.json "$CHECKSUMS_URL"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
curl -fsSLo "$TMP/checksums.json" "$CHECKSUMS_URL"
|
||||
|
||||
INSTALLED_VER="$(jq -r '.version // ""' "$SUITE_DIR/skill.json" 2>/dev/null || true)"
|
||||
LATEST_VER="$(jq -r '.version // ""' checksums.json 2>/dev/null || true)"
|
||||
LATEST_VER="$(jq -r '.version // ""' "$TMP/checksums.json" 2>/dev/null || true)"
|
||||
|
||||
echo "Installed suite: ${INSTALLED_VER:-unknown}"
|
||||
echo "Latest suite: ${LATEST_VER:-unknown}"
|
||||
|
||||
if [ -n "$LATEST_VER" ] && [ "$LATEST_VER" != "$INSTALLED_VER" ]; then
|
||||
echo "UPDATE AVAILABLE: clawsec-suite ${INSTALLED_VER:-unknown} -> $LATEST_VER"
|
||||
echo "(Implement your runtime-specific update action here.)"
|
||||
else
|
||||
echo "Suite appears up to date."
|
||||
fi
|
||||
```
|
||||
|
||||
If your runtime does not have `jq`, you can parse the version line with grep/sed, or we can publish a simpler `latest.txt` endpoint.
|
||||
---
|
||||
|
||||
## Step 2 — Initialize advisory state
|
||||
|
||||
```bash
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
|
||||
if ! jq -e '.schema_version and .known_advisories' "$STATE_FILE" >/dev/null 2>&1; then
|
||||
echo "WARNING: Invalid state file, resetting: $STATE_FILE"
|
||||
cp "$STATE_FILE" "${STATE_FILE}.bak.$(date -u +%Y%m%d%H%M%S)" 2>/dev/null || true
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
## Step 3 — Advisory feed check (embedded clawsec-feed)
|
||||
|
||||
This heartbeat should print a short report suitable for being copied into an alert message:
|
||||
```bash
|
||||
now_epoch="$(date -u +%s)"
|
||||
last_check="$(jq -r '.last_feed_check // "1970-01-01T00:00:00Z"' "$STATE_FILE")"
|
||||
last_epoch="$(date -u -d "$last_check" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$last_check" +%s 2>/dev/null || echo 0)"
|
||||
|
||||
- suite version status
|
||||
- integrity status
|
||||
if [ $((now_epoch - last_epoch)) -lt "$MIN_FEED_INTERVAL_SECONDS" ]; then
|
||||
echo "Feed check skipped (rate limit: ${MIN_FEED_INTERVAL_SECONDS}s)."
|
||||
else
|
||||
FEED_TMP="$TMP/feed.json"
|
||||
FEED_SOURCE="$FEED_URL"
|
||||
|
||||
if ! curl -fsSLo "$FEED_TMP" "$FEED_URL"; then
|
||||
if [ -f "$SUITE_DIR/advisories/feed.json" ]; then
|
||||
cp "$SUITE_DIR/advisories/feed.json" "$FEED_TMP"
|
||||
FEED_SOURCE="$SUITE_DIR/advisories/feed.json (local fallback)"
|
||||
echo "WARNING: Remote feed unavailable, using local fallback."
|
||||
else
|
||||
echo "ERROR: Remote feed unavailable and no local fallback feed found."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! jq -e '.version and (.advisories | type == "array")' "$FEED_TMP" >/dev/null 2>&1; then
|
||||
echo "ERROR: Advisory feed has invalid format."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Feed source: $FEED_SOURCE"
|
||||
echo "Feed updated: $(jq -r '.updated // "unknown"' "$FEED_TMP")"
|
||||
|
||||
NEW_IDS_FILE="$TMP/new_ids.txt"
|
||||
jq -r --argfile state "$STATE_FILE" '($state.known_advisories // []) as $known | [.advisories[]?.id | select(. != null and ($known | index(.) | not))] | .[]?' "$FEED_TMP" > "$NEW_IDS_FILE"
|
||||
|
||||
if [ -s "$NEW_IDS_FILE" ]; then
|
||||
echo "New advisories:"
|
||||
while IFS= read -r id; do
|
||||
[ -z "$id" ] && continue
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$FEED_TMP"
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | " Action: \(.action // "Review advisory details")"' "$FEED_TMP"
|
||||
done < "$NEW_IDS_FILE"
|
||||
else
|
||||
echo "FEED_OK - no new advisories"
|
||||
fi
|
||||
|
||||
echo "Affected installed skills (if any):"
|
||||
found_affected=0
|
||||
removal_recommended=0
|
||||
for skill_path in "$INSTALL_ROOT"/*; do
|
||||
[ -d "$skill_path" ] || continue
|
||||
skill_name="$(basename "$skill_path")"
|
||||
|
||||
skill_hits="$(jq -r --arg skill_prefix "${skill_name}@" '
|
||||
[.advisories[]
|
||||
| select(any(.affected[]?; startswith($skill_prefix)))
|
||||
| "- [\(.severity | ascii_upcase)] \(.id): \(.title)\n Action: \(.action // "Review advisory details")"
|
||||
] | .[]?
|
||||
' "$FEED_TMP")"
|
||||
|
||||
if [ -n "$skill_hits" ]; then
|
||||
found_affected=1
|
||||
echo "- $skill_name is referenced by advisory feed entries"
|
||||
printf "%s\n" "$skill_hits"
|
||||
|
||||
if jq -e --arg skill_prefix "${skill_name}@" '
|
||||
any(
|
||||
.advisories[];
|
||||
any(.affected[]?; startswith($skill_prefix))
|
||||
and (
|
||||
((.type // "" | ascii_downcase) == "malicious_skill")
|
||||
or ((.title // "" | ascii_downcase | test("malicious|exfiltrat|backdoor|trojan|stealer")))
|
||||
or ((.description // "" | ascii_downcase | test("malicious|exfiltrat|backdoor|trojan|stealer")))
|
||||
or ((.action // "" | ascii_downcase | test("remove|uninstall|disable|do not use|quarantine")))
|
||||
)
|
||||
)
|
||||
' "$FEED_TMP" >/dev/null 2>&1; then
|
||||
removal_recommended=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found_affected" -eq 0 ]; then
|
||||
echo "- none"
|
||||
fi
|
||||
|
||||
if [ "$removal_recommended" -eq 1 ]; then
|
||||
echo "Approval required: ask the user for explicit approval before removing any skill."
|
||||
echo "Double-confirmation policy: install request is first intent; require a second explicit confirmation with advisory context."
|
||||
fi
|
||||
|
||||
# Persist state
|
||||
current_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
state_tmp="$TMP/state.json"
|
||||
|
||||
jq --arg t "$current_utc" --arg updated "$(jq -r '.updated // ""' "$FEED_TMP")" --argfile feed "$FEED_TMP" '
|
||||
.last_feed_check = $t
|
||||
| .last_feed_updated = (if $updated == "" then .last_feed_updated else $updated end)
|
||||
| .known_advisories = ((.known_advisories // []) + [$feed.advisories[]?.id] | map(select(. != null)) | unique)
|
||||
' "$STATE_FILE" > "$state_tmp"
|
||||
|
||||
mv "$state_tmp" "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Summary
|
||||
|
||||
Heartbeat output should include:
|
||||
- suite version status,
|
||||
- advisory feed status,
|
||||
- new advisory list (if any),
|
||||
- installed skills that appear in advisory `affected` lists,
|
||||
- and a double-confirmation reminder before risky install/remove actions.
|
||||
|
||||
If your runtime sends alerts, treat `critical` and `high` advisories affecting installed skills as immediate notifications.
|
||||
|
||||
+325
-300
@@ -1,358 +1,383 @@
|
||||
---
|
||||
name: clawsec-suite
|
||||
version: 0.0.3
|
||||
description: Use when users or agents want to explore or set up ClawSec security protections - this is a wrapper that helps discover and install individual security skills, but requires initial configuration to activate any protections.
|
||||
version: 0.1.3
|
||||
description: ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "📦"
|
||||
requires:
|
||||
bins: [curl, shasum]
|
||||
bins: [curl, jq, shasum, openssl]
|
||||
---
|
||||
|
||||
# ClawSec Suite
|
||||
|
||||
## Installation steps
|
||||
This means `clawsec-suite` can:
|
||||
- monitor the ClawSec advisory feed,
|
||||
- track which advisories are new since last check,
|
||||
- cross-reference advisories against locally installed skills,
|
||||
- recommend removal for malicious-skill advisories and require explicit user approval first,
|
||||
- and still act as the setup/management entrypoint for other ClawSec protections.
|
||||
|
||||
**IMPORTANT**: ClawSec Suite includes bundled security skills that provide crucial threat intelligence and monitoring capabilities. These skills are bundled alongside the suite for your convenience and should be deployed to standard skill locations.
|
||||
## Included vs Optional Protections
|
||||
|
||||
The suite includes the following bundled skills:
|
||||
- **clawsec-feed**: Provides crucial CVE and security advisory information
|
||||
- **openclaw-audit-watchdog**: Provides crucial automated security audit capabilities
|
||||
- **soul-guardian**: Provides file integrity and drift detection
|
||||
### Built into clawsec-suite
|
||||
- Embedded feed seed file: `advisories/feed.json`
|
||||
- Portable heartbeat workflow in `HEARTBEAT.md`
|
||||
- Advisory polling + state tracking + affected-skill checks
|
||||
- OpenClaw advisory guardian hook package: `hooks/clawsec-advisory-guardian/`
|
||||
- Setup scripts for hook and optional cron scheduling: `scripts/`
|
||||
- Guarded installer: `scripts/guarded_skill_install.mjs`
|
||||
- Dynamic catalog discovery for installable skills: `scripts/discover_skill_catalog.mjs`
|
||||
|
||||
Finalize deployment with post-script after suite installation:
|
||||
### Installed separately (dynamic catalog)
|
||||
`clawsec-suite` does not hard-code add-on skill names in this document.
|
||||
|
||||
Discover the current catalog from the authoritative index (`https://clawsec.prompt.security/skills/index.json`) at runtime:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# BUNDLED SKILLS DEPLOYMENT
|
||||
# These skills are bundled alongside the suite to provide crucial security information
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
SUITE_DIR="$INSTALL_ROOT/clawsec-suite"
|
||||
|
||||
echo "=== Deploying bundled security skills ==="
|
||||
|
||||
# Function to deploy a bundled skill
|
||||
deploy_skill() {
|
||||
local skill_name="$1"
|
||||
local skill_dir="$INSTALL_ROOT/$skill_name"
|
||||
local bundled_dir="$SUITE_DIR/bundled/$skill_name"
|
||||
|
||||
# Check if skill already exists
|
||||
if [ -d "$skill_dir" ] && [ -f "$skill_dir/skill.json" ]; then
|
||||
INSTALLED_VERSION=$(jq -r '.version // "unknown"' "$skill_dir/skill.json" 2>/dev/null)
|
||||
echo "$skill_name already installed (v${INSTALLED_VERSION}), preserving existing installation"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check if bundled version exists
|
||||
if [ ! -d "$bundled_dir" ] || [ ! -f "$bundled_dir/skill.json" ]; then
|
||||
echo "Bundled $skill_name not found, skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Deploy bundled skill
|
||||
echo "Deploying bundled $skill_name..."
|
||||
mkdir -p "$skill_dir"
|
||||
cp -r "$bundled_dir"/* "$skill_dir/"
|
||||
|
||||
# Set proper permissions
|
||||
chmod 600 "$skill_dir/skill.json"
|
||||
find "$skill_dir" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||
|
||||
# Verify installation
|
||||
if [ -f "$skill_dir/skill.json" ]; then
|
||||
SKILL_VERSION=$(jq -r '.version' "$skill_dir/skill.json")
|
||||
echo "✓ $skill_name v${SKILL_VERSION} deployed successfully"
|
||||
else
|
||||
echo "✗ $skill_name deployment verification failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Deploy each bundled skill
|
||||
deploy_skill "clawsec-feed"
|
||||
deploy_skill "openclaw-audit-watchdog"
|
||||
deploy_skill "soul-guardian"
|
||||
|
||||
echo ""
|
||||
echo "✓ ClawSec Suite deployment complete"
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/discover_skill_catalog.mjs"
|
||||
```
|
||||
|
||||
### ✅ On Successful Installation (IMPORTANT)
|
||||
Fallback behavior:
|
||||
- If the remote catalog index is reachable and valid, the suite uses it.
|
||||
- If the remote index is unavailable or malformed, the script falls back to suite-local catalog metadata in `skill.json`.
|
||||
|
||||
On success the user should know: which skills were installed, that protections are active, and where to learn more if interested.
|
||||
Offered template:
|
||||
## Installation
|
||||
|
||||
```
|
||||
**ClawSec Suite installation is complete — you are now protected!**
|
||||
### Cross-shell path note
|
||||
|
||||
The following security skills are active with their default configurations:
|
||||
• clawsec-feed — Security advisory monitoring (CVE/threat intelligence)
|
||||
• openclaw-audit-watchdog — Automated security audits
|
||||
• soul-guardian — File integrity and drift detection
|
||||
- In `bash`/`zsh`, keep path variables expandable (for example, `INSTALL_ROOT="$HOME/.openclaw/skills"`).
|
||||
- Do not single-quote home-variable paths (avoid `'$HOME/.openclaw/skills'`).
|
||||
- In PowerShell, set an explicit path:
|
||||
- `$env:INSTALL_ROOT = Join-Path $HOME ".openclaw\\skills"`
|
||||
- If a path is passed with unresolved tokens (like `\$HOME/...`), suite scripts now fail fast with a clear error.
|
||||
|
||||
If you'd like to customize any configuration or learn more about these protections, just ask.
|
||||
### Option A: Via clawhub (recommended)
|
||||
|
||||
Provided to the open-source community with care from Prompt Security, a SentinelOne Company.
|
||||
```bash
|
||||
npx clawhub@latest install clawsec-suite
|
||||
```
|
||||
|
||||
### ⚠️ On Partial Installation or Errors
|
||||
|
||||
If any skill fails to deploy or verification errors occur, inform the user with details:
|
||||
|
||||
```
|
||||
ClawSec Suite installation completed with warnings.
|
||||
|
||||
Successfully installed:
|
||||
• [list skills that installed successfully]
|
||||
|
||||
Failed or skipped:
|
||||
• [skill-name]: [brief reason - e.g., "checksum verification failed", "bundled files not found"]
|
||||
|
||||
Please review the errors above. You can retry installation or ask for help troubleshooting.
|
||||
|
||||
Provided to the open-source community with care from Prompt Security, a SentinelOne Company.
|
||||
```
|
||||
|
||||
## Contract (what this skill is)
|
||||
|
||||
ClawSec Suite is a small "manager" package that helps an agent runtime:
|
||||
|
||||
- install ClawSec security components
|
||||
- verify integrity of installed files (checksums + optional signatures)
|
||||
- check for updates from a signed checksums.json manifest
|
||||
- run a periodic "health check" (security skill updates + chain of supply integrity + advisories)
|
||||
|
||||
This document is written to be **portable**: it does not assume a specific agent framework or CLI. However it was tested to work best with OpenClaw, Moltbot, Clawdbot and variants
|
||||
|
||||
## 🎯 Let's Get You Protected
|
||||
|
||||
Great! You've installed the ClawSec Suite wrapper. Now let's get you set up with the security protections you came here for.
|
||||
|
||||
**Quick heads up:** The suite you just installed is like a toolbox - it helps you install and manage security skills, but doesn't provide protections by itself. Think of it as your security skill manager.
|
||||
|
||||
**Here's how to activate your protections:**
|
||||
|
||||
1. Browse the available security skills (see below, or browse to https://clawsec.prompt.security/)
|
||||
2. Choose which protections match your needs
|
||||
3. Install your selected skills using the installation guide
|
||||
4. Auto Configure defaults for each skill for your environment.
|
||||
|
||||
Each skill protects against different threats - from security advisories to file tampering. Let's explore your options and get you secured.
|
||||
|
||||
## Available Security Skills
|
||||
|
||||
The ClawSec suite includes these security skills:
|
||||
|
||||
| Skill | Protection Type | Installation |
|
||||
|-------|----------------|-------------|
|
||||
| **clawsec-feed** | Security advisory monitoring from NVD CVE database | ✓ Included by default |
|
||||
| **openclaw-audit-watchdog** | Automated daily security audits with email reporting | ✓ Included by default |
|
||||
| **soul-guardian** | File integrity & drift detection for agent workspace files | ✗ Optional |
|
||||
| **clawtributor** | Community incident reporting (shares anonymized data)* | ✗ Optional (Opt-in) |
|
||||
|
||||
**Clawtributor requires explicit consent** as it shares anonymized data with the community for collective threat intelligence.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Step 1: Review Available Skills
|
||||
Take a moment to review the security skills table above. Each skill provides a different layer of protection:
|
||||
|
||||
- **Threat awareness** (clawsec-feed): Stay informed about vulnerabilities [Included by default]
|
||||
- **Scheduled audits** (openclaw-audit-watchdog): Daily automated security reviews [Included by default]
|
||||
- **File integrity** (soul-guardian): Detect unauthorized changes to workspace files [Optional]
|
||||
- **Community intelligence** (clawtributor): Share and receive threat data [Optional - Opt-in]
|
||||
|
||||
### Step 2: Choose Your Protection Level
|
||||
|
||||
**default protections** (automatically installed):
|
||||
- clawsec-feed
|
||||
- openclaw-audit-watchdog
|
||||
|
||||
**Optional protections** (install as needed):
|
||||
- soul-guardian (file integrity and drift detection)
|
||||
- clawtributor (community threat intelligence - requires explicit consent for data sharing)
|
||||
|
||||
### Step 3: Bundled Security Skills
|
||||
|
||||
Good news! The following security skills are bundled alongside ClawSec Suite:
|
||||
|
||||
✓ **clawsec-feed** - Provides crucial CVE and threat intelligence information
|
||||
✓ **openclaw-audit-watchdog** - Provides automated security audit capabilities
|
||||
✓ **soul-guardian** - Provides file integrity and drift detection
|
||||
|
||||
These skills are included in the suite package for your convenience.
|
||||
|
||||
**Deployment options:**
|
||||
- If you don't already have these skills installed, they will be deployed from the bundled copies
|
||||
- If you already have any of these skills, your existing installations will be preserved
|
||||
- Each skill is installed to its standard location: `~/.openclaw/skills/{skill-name}/`
|
||||
|
||||
**What this means:**
|
||||
- Convenient setup - no separate downloads needed if you don't have them
|
||||
- Standard installation - follows skill conventions
|
||||
- Independent management - each skill can be configured separately
|
||||
- Always current - feed data is fetched online (always up-to-date)
|
||||
|
||||
### Step 4: Optional Opt-In Component
|
||||
|
||||
**clawtributor** (Community threat intelligence):
|
||||
- Share and receive anonymized threat data
|
||||
- Requires explicit opt-in and user consent
|
||||
- Not bundled with suite (available as separate download)
|
||||
- Installation instructions: https://clawsec.prompt.security/skills/clawtributor
|
||||
|
||||
### Step 5: Configure Each Skill
|
||||
|
||||
After installation, each skill requires configuration, to reduce security fatigue start with the defaults for soul guardian (and dont use email sending for audit reports in watchdog):
|
||||
|
||||
- **clawsec-feed**: Configure update frequency and alert preferences
|
||||
- **openclaw-audit-watchdog**: Set email addresses for audit reports
|
||||
- **soul-guardian**: Set baseline checksums for protected files
|
||||
- **clawtributor**: Provide explicit consent and configure data sharing preferences (if installed)
|
||||
|
||||
Refer to each skill's SKILL.md file for detailed configuration instructions. if the user wants to change the defaults.
|
||||
|
||||
## Trust, provenance, and safety
|
||||
|
||||
**Do not install anything you cannot verify.** A secure installer must:
|
||||
|
||||
1) download artifacts from a stable origin
|
||||
2) verify integrity (checksum)
|
||||
3) ideally verify authenticity (signature)
|
||||
4) fail closed (stop on any mismatch)
|
||||
|
||||
If your project does not yet publish signatures, publish at least:
|
||||
|
||||
- `checksums.json` (sha256 hashes + version metadata)
|
||||
|
||||
### Canonical release origin
|
||||
|
||||
All releases are published at:
|
||||
|
||||
- `https://clawsec.prompt.security/releases/download/<SKILL>-v<VERSION>/...`
|
||||
|
||||
Example for clawsec-suite v0.0.9:
|
||||
|
||||
- `https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.3/checksums.json`
|
||||
- `https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.3/clawsec-suite.skill`
|
||||
|
||||
## Installation (generic, version-pinned, verified)
|
||||
|
||||
### Requirements
|
||||
|
||||
- `curl`
|
||||
- `jq` (for parsing checksums.json)
|
||||
- `unzip`
|
||||
- a SHA-256 tool (`shasum -a 256` on macOS, or `sha256sum` on Linux)
|
||||
|
||||
### Install steps
|
||||
|
||||
Pick a stable install root:
|
||||
|
||||
- `INSTALL_ROOT` default: `~/.openclaw/skills`
|
||||
|
||||
> If your agent runtime has its own skills directory, set `INSTALL_ROOT` accordingly.
|
||||
### Option B: Manual download with signature + checksum verification
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${VERSION:-0.0.3}"
|
||||
VERSION="${SKILL_VERSION:?Set SKILL_VERSION (e.g. 0.0.8)}"
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
DEST="$INSTALL_ROOT/clawsec-suite"
|
||||
BASE="https://github.com/prompt-security/clawsec/releases/download/clawsec-suite-v${VERSION}"
|
||||
|
||||
BASE="https://clawsec.prompt.security/releases/download/clawsec-suite-v${VERSION}"
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cd "$(mktemp -d)"
|
||||
# Pinned release-signing public key (verify fingerprint out-of-band on first use)
|
||||
# Fingerprint (SHA-256 of SPKI DER): 711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8
|
||||
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
|
||||
cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM'
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
PEM
|
||||
|
||||
# 1) Download checksums.json and artifact
|
||||
curl -fsSL "$BASE/checksums.json" -o checksums.json
|
||||
curl -fsSL "$BASE/clawsec-suite.skill" -o clawsec-suite.skill
|
||||
|
||||
# 2) Extract expected checksum from checksums.json
|
||||
EXPECTED_SHA256=$(jq -r '.files["clawsec-suite.skill"].sha256' checksums.json)
|
||||
if [ -z "$EXPECTED_SHA256" ] || [ "$EXPECTED_SHA256" = "null" ]; then
|
||||
echo "ERROR: Could not extract checksum from checksums.json" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 3) Compute actual checksum
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
ACTUAL_SHA256=$(shasum -a 256 clawsec-suite.skill | awk '{print $1}')
|
||||
else
|
||||
ACTUAL_SHA256=$(sha256sum clawsec-suite.skill | awk '{print $1}')
|
||||
fi
|
||||
|
||||
# 4) Verify checksum (fail closed)
|
||||
if [ "$EXPECTED_SHA256" != "$ACTUAL_SHA256" ]; then
|
||||
echo "ERROR: Checksum mismatch!" >&2
|
||||
echo " Expected: $EXPECTED_SHA256" >&2
|
||||
echo " Actual: $ACTUAL_SHA256" >&2
|
||||
ACTUAL_KEY_SHA256="$(openssl pkey -pubin -in "$TEMP_DIR/release-signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
|
||||
if [ "$ACTUAL_KEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
|
||||
echo "ERROR: Release public key fingerprint mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Checksum verified: $ACTUAL_SHA256"
|
||||
|
||||
# 5) Install
|
||||
rm -rf "$DEST"/*
|
||||
unzip -oq clawsec-suite.skill -d "$DEST"
|
||||
ZIP_NAME="clawsec-suite-v${VERSION}.zip"
|
||||
|
||||
# 6) Sanity check
|
||||
test -f "$DEST/skill.json"
|
||||
test -f "$DEST/SKILL.md"
|
||||
test -f "$DEST/HEARTBEAT.md"
|
||||
# 1) Download release archive + signed checksums manifest + signing public key
|
||||
curl -fsSL "$BASE/$ZIP_NAME" -o "$TEMP_DIR/$ZIP_NAME"
|
||||
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
|
||||
curl -fsSL "$BASE/checksums.sig" -o "$TEMP_DIR/checksums.sig"
|
||||
|
||||
echo "Installed ClawSec Suite v${VERSION} to: $DEST"
|
||||
# 2) Verify checksums manifest signature before trusting any hashes
|
||||
openssl base64 -d -A -in "$TEMP_DIR/checksums.sig" -out "$TEMP_DIR/checksums.sig.bin"
|
||||
if ! openssl pkeyutl -verify \
|
||||
-pubin \
|
||||
-inkey "$TEMP_DIR/release-signing-public.pem" \
|
||||
-sigfile "$TEMP_DIR/checksums.sig.bin" \
|
||||
-rawin \
|
||||
-in "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: checksums.json signature verification failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECTED_ZIP_SHA="$(jq -r '.archive.sha256 // empty' "$TEMP_DIR/checksums.json")"
|
||||
if [ -z "$EXPECTED_ZIP_SHA" ]; then
|
||||
echo "ERROR: checksums.json missing archive.sha256" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
ACTUAL_ZIP_SHA="$(shasum -a 256 "$TEMP_DIR/$ZIP_NAME" | awk '{print $1}')"
|
||||
else
|
||||
ACTUAL_ZIP_SHA="$(sha256sum "$TEMP_DIR/$ZIP_NAME" | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
if [ "$EXPECTED_ZIP_SHA" != "$ACTUAL_ZIP_SHA" ]; then
|
||||
echo "ERROR: Archive checksum mismatch for $ZIP_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checksums manifest signature and archive hash verified."
|
||||
|
||||
# 3) Install verified archive
|
||||
mkdir -p "$INSTALL_ROOT"
|
||||
rm -rf "$DEST"
|
||||
unzip -q "$TEMP_DIR/$ZIP_NAME" -d "$INSTALL_ROOT"
|
||||
|
||||
chmod 600 "$DEST/skill.json"
|
||||
find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||
|
||||
echo "Installed clawsec-suite v${VERSION} to: $DEST"
|
||||
echo "Next step (OpenClaw): node \"\$DEST/scripts/setup_advisory_hook.mjs\""
|
||||
```
|
||||
|
||||
### What this does (disclosure)
|
||||
## OpenClaw Automation (Hook + Optional Cron)
|
||||
|
||||
**Installing clawsec-suite:**
|
||||
- Writes only under: `$DEST` (default `~/.openclaw/skills/clawsec-suite`)
|
||||
- Makes network requests only to fetch the suite artifact + checksums (and optionally signatures)
|
||||
- Does **not** provide any security protections by itself - it's just the wrapper/manager
|
||||
- Does **not** auto-install any security skills - you choose which skills to install
|
||||
- Does **not** auto-enable telemetry/community reporting
|
||||
- Does **not** schedule anything automatically
|
||||
After installing the suite, enable the advisory guardian hook:
|
||||
|
||||
**To get actual security protections**, you need to install and configure individual security skills (see "Getting Started" above).
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/setup_advisory_hook.mjs"
|
||||
```
|
||||
|
||||
## Update checking (portable design)
|
||||
Optional: create/update a periodic cron nudge (default every `6h`) that triggers a main-session advisory scan:
|
||||
|
||||
Each release publishes a `checksums.json` file that contains version info and SHA256 hashes for all artifacts:
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/setup_advisory_cron.mjs"
|
||||
```
|
||||
|
||||
- `https://clawsec.prompt.security/releases/download/clawsec-suite-v<VERSION>/checksums.json`
|
||||
What this adds:
|
||||
- scan on `agent:bootstrap` and `/new` (`command:new`),
|
||||
- compare advisory `affected` entries against installed skills,
|
||||
- consider advisories with `application: "openclaw"` (and legacy entries without `application` for backward compatibility),
|
||||
- notify when new matches appear,
|
||||
- and ask for explicit user approval before any removal flow.
|
||||
|
||||
Restart the OpenClaw gateway after enabling the hook. Then run `/new` once to force an immediate scan in the next session context.
|
||||
|
||||
The checksums.json structure:
|
||||
## Guarded Skill Install Flow (Double Confirmation)
|
||||
|
||||
When the user asks to install a skill, treat that as the first request and run a guarded install check:
|
||||
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --version 1.0.1
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- If no advisory match is found, install proceeds.
|
||||
- If `--version` is omitted, matching is conservative: any advisory that references the skill name is treated as a match.
|
||||
- If advisory match is found, the script prints advisory context and exits with code `42`.
|
||||
- Then require an explicit second confirmation from the user and rerun with `--confirm-advisory`:
|
||||
|
||||
```bash
|
||||
node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --version 1.0.1 --confirm-advisory
|
||||
```
|
||||
|
||||
This enforces:
|
||||
1. First confirmation: user asked to install.
|
||||
2. Second confirmation: user explicitly approves install after seeing advisory details.
|
||||
|
||||
## Embedded Advisory Feed Behavior
|
||||
|
||||
The embedded feed logic uses these defaults:
|
||||
|
||||
- Remote feed URL: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
- Remote feed signature URL: `${CLAWSEC_FEED_URL}.sig` (override with `CLAWSEC_FEED_SIG_URL`)
|
||||
- Remote checksums manifest URL: sibling `checksums.json` (override with `CLAWSEC_FEED_CHECKSUMS_URL`)
|
||||
- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json`
|
||||
- Local feed signature: `${CLAWSEC_LOCAL_FEED}.sig` (override with `CLAWSEC_LOCAL_FEED_SIG`)
|
||||
- Local checksums manifest: `~/.openclaw/skills/clawsec-suite/advisories/checksums.json`
|
||||
- Pinned feed signing key: `~/.openclaw/skills/clawsec-suite/advisories/feed-signing-public.pem` (override with `CLAWSEC_FEED_PUBLIC_KEY`)
|
||||
- State file: `~/.openclaw/clawsec-suite-feed-state.json`
|
||||
- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`)
|
||||
|
||||
**Fail-closed verification:** Feed signatures are required by default. Checksum manifests are verified when companion checksum artifacts are available. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream.
|
||||
|
||||
### Quick feed check
|
||||
|
||||
```bash
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-https://clawsec.prompt.security/advisories/feed.json}"
|
||||
STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
if ! curl -fsSLo "$TMP/feed.json" "$FEED_URL"; then
|
||||
echo "ERROR: Failed to fetch advisory feed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.version and (.advisories | type == "array")' "$TMP/feed.json" >/dev/null; then
|
||||
echo "ERROR: Invalid advisory feed format"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
|
||||
NEW_IDS_FILE="$TMP/new_ids.txt"
|
||||
jq -r --argfile state "$STATE_FILE" '($state.known_advisories // []) as $known | [.advisories[]?.id | select(. != null and ($known | index(.) | not))] | .[]?' "$TMP/feed.json" > "$NEW_IDS_FILE"
|
||||
|
||||
if [ -s "$NEW_IDS_FILE" ]; then
|
||||
echo "New advisories detected:"
|
||||
while IFS= read -r id; do
|
||||
[ -z "$id" ] && continue
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$TMP/feed.json"
|
||||
done < "$NEW_IDS_FILE"
|
||||
else
|
||||
echo "FEED_OK - no new advisories"
|
||||
fi
|
||||
```
|
||||
|
||||
## Heartbeat Integration
|
||||
|
||||
Use the suite heartbeat script as the single periodic security check entrypoint:
|
||||
|
||||
- `skills/clawsec-suite/HEARTBEAT.md`
|
||||
|
||||
It handles:
|
||||
- suite update checks,
|
||||
- feed polling,
|
||||
- new-advisory detection,
|
||||
- affected-skill cross-referencing,
|
||||
- approval-gated response guidance for malicious/removal advisories,
|
||||
- and persistent state updates.
|
||||
|
||||
## Approval-Gated Response Contract
|
||||
|
||||
If an advisory indicates a malicious or removal-recommended skill and that skill is installed:
|
||||
|
||||
1. Notify the user immediately with advisory details and severity.
|
||||
2. Recommend removing or disabling the affected skill.
|
||||
3. Treat the original install request as first intent only.
|
||||
4. Ask for explicit second confirmation before deletion/disable action (or before proceeding with risky install).
|
||||
5. Only proceed after that second confirmation.
|
||||
|
||||
The suite hook and heartbeat guidance are intentionally non-destructive by default.
|
||||
|
||||
## Advisory Suppression / Allowlist
|
||||
|
||||
The advisory guardian pipeline supports opt-in suppression for advisories that have been reviewed and accepted by your security team. This is useful for first-party tooling or advisories that do not apply to your deployment.
|
||||
|
||||
### Activation
|
||||
|
||||
Advisory suppression requires a single gate: the configuration file must contain `"enabledFor"` with `"advisory"` in the array. No CLI flag is needed -- the sentinel in the config file IS the opt-in gate.
|
||||
|
||||
If the `enabledFor` array is missing, empty, or does not include `"advisory"`, all advisories are reported normally.
|
||||
|
||||
### Config File Resolution (4-tier)
|
||||
|
||||
The advisory guardian resolves the suppression config using the same priority order as the audit pipeline:
|
||||
|
||||
1. Explicit `--config <path>` argument
|
||||
2. `OPENCLAW_AUDIT_CONFIG` environment variable
|
||||
3. `~/.openclaw/security-audit.json`
|
||||
4. `.clawsec/allowlist.json`
|
||||
|
||||
### Config Format
|
||||
|
||||
```json
|
||||
{
|
||||
"skill": "clawsec-suite",
|
||||
"version": "0.0.3",
|
||||
"generated_at": "2026-02-04T23:42:57Z",
|
||||
"repository": "prompt-security/ClawSec",
|
||||
"tag": "clawsec-suite-v0.0.3",
|
||||
"files": {
|
||||
"clawsec-suite.skill": {
|
||||
"sha256": "339a4817aba054e6da5a6d838e2603d16592b43f6bdb7265d6b1918b22fe62cb",
|
||||
"size": 4870,
|
||||
"url": "https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.3/clawsec-suite.skill"
|
||||
"enabledFor": ["advisory"],
|
||||
"suppressions": [
|
||||
{
|
||||
"checkId": "CVE-2026-25593",
|
||||
"skill": "clawsec-suite",
|
||||
"reason": "First-party security tooling — reviewed by security team",
|
||||
"suppressedAt": "2026-02-15"
|
||||
},
|
||||
{
|
||||
"checkId": "CLAW-2026-0001",
|
||||
"skill": "example-skill",
|
||||
"reason": "Advisory does not apply to our deployment configuration",
|
||||
"suppressedAt": "2026-02-16"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To check for updates, compare the installed version against the latest `checksums.json`. See `HEARTBEAT.md` for the upgrade check procedure.
|
||||
### Sentinel Semantics
|
||||
|
||||
## Platform adapters (optional sections)
|
||||
- `"enabledFor": ["advisory"]` -- only advisory suppression active
|
||||
- `"enabledFor": ["audit"]` -- only audit suppression active (no effect on advisory pipeline)
|
||||
- `"enabledFor": ["audit", "advisory"]` -- both pipelines honor suppressions
|
||||
- Missing or empty `enabledFor` -- no suppression active (safe default)
|
||||
|
||||
If you want this to work well everywhere, add short adapter sections that only map:
|
||||
### Matching Rules
|
||||
|
||||
- install directory
|
||||
- scheduler integration
|
||||
- message/alert delivery integration
|
||||
- **checkId:** exact match against the advisory ID (e.g., `CVE-2026-25593` or `CLAW-2026-0001`)
|
||||
- **skill:** case-insensitive match against the affected skill name from the advisory
|
||||
- Both fields must match for an advisory to be suppressed
|
||||
|
||||
Keep the core verify/install/update logic identical.
|
||||
### Required Fields per Suppression Entry
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| `checkId` | Advisory ID to suppress | `CVE-2026-25593` |
|
||||
| `skill` | Affected skill name | `clawsec-suite` |
|
||||
| `reason` | Justification for audit trail (required) | `First-party tooling, reviewed by security team` |
|
||||
| `suppressedAt` | ISO 8601 date (YYYY-MM-DD) | `2026-02-15` |
|
||||
|
||||
### Shared Config with Audit Pipeline
|
||||
|
||||
The advisory and audit pipelines share the same config file. Use the `enabledFor` array to control which pipelines honor the suppression list:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabledFor": ["audit", "advisory"],
|
||||
"suppressions": [
|
||||
{
|
||||
"checkId": "skills.code_safety",
|
||||
"skill": "clawsec-suite",
|
||||
"reason": "First-party tooling — audit finding accepted",
|
||||
"suppressedAt": "2026-02-15"
|
||||
},
|
||||
{
|
||||
"checkId": "CVE-2026-25593",
|
||||
"skill": "clawsec-suite",
|
||||
"reason": "First-party tooling — advisory reviewed",
|
||||
"suppressedAt": "2026-02-15"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Audit entries (with check identifiers like `skills.code_safety`) are only matched by the audit pipeline. Advisory entries (with advisory IDs like `CVE-2026-25593` or `CLAW-2026-0001`) are only matched by the advisory pipeline. Each pipeline filters for its own relevant entries.
|
||||
|
||||
## Optional Skill Installation
|
||||
|
||||
Discover currently available installable skills dynamically, then install the ones you want:
|
||||
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/discover_skill_catalog.mjs"
|
||||
|
||||
# then install any discovered skill by name
|
||||
npx clawhub@latest install <skill-name>
|
||||
```
|
||||
|
||||
Machine-readable output is also available for automation:
|
||||
|
||||
```bash
|
||||
node "$SUITE_DIR/scripts/discover_skill_catalog.mjs" --json
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Always verify `checksums.json` signature before trusting its file URLs/hashes, then verify each file checksum.
|
||||
- Verify advisory feed detached signatures; do not enable `CLAWSEC_ALLOW_UNSIGNED_FEED` outside temporary migration windows.
|
||||
- Keep advisory polling rate-limited (at least 5 minutes between checks).
|
||||
- Treat `critical` and `high` advisories affecting installed skills as immediate action items.
|
||||
- If you migrate off standalone `clawsec-feed`, keep one canonical state file to avoid duplicate notifications.
|
||||
- Pin and verify public key fingerprints out-of-band before first use.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"updated": "2026-02-08T06:16:28Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:07.287",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-r8g4-86fx-92mq"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25475"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25157",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:06.577",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-q284-4pvr-m585"
|
||||
],
|
||||
"cvss_score": 7.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157"
|
||||
},
|
||||
{
|
||||
"id": "CLAW-2026-0001",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection",
|
||||
"title": "Data exfiltration attempt via helper-plus skill",
|
||||
"description": "The helper-plus skill was observed sending conversation data to an external server (suspicious-domain.com) on every invocation. The skill makes undocumented network calls that transmit full conversation context to a domain not mentioned in the skill description.",
|
||||
"affected": [
|
||||
"helper-plus@1.0.0",
|
||||
"helper-plus@1.0.1"
|
||||
],
|
||||
"action": "Remove helper-plus immediately. Do not use versions 1.0.0 or 1.0.1. Wait for a verified patched version.",
|
||||
"published": "2026-02-04T09:30:00Z",
|
||||
"references": [],
|
||||
"source": "Community Report",
|
||||
"github_issue_url": "https://github.com/prompt-security/clawsec/issues/1",
|
||||
"reporter": {
|
||||
"agent_name": "SecurityBot",
|
||||
"opener_type": "agent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24763",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/771f23d36b95ec2204cc9a0054045f5d8439ea75",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.1.29",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-mc68-q9jw-2h3v"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24763"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25253",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...",
|
||||
"description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-01T23:15:49.717",
|
||||
"references": [
|
||||
"https://depthfirst.com/post/1-click-rce-to-steal-your-moltbot-data-and-keys",
|
||||
"https://ethiack.com/news/blog/one-click-rce-moltbot",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g8p2-7wf7-98mq"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25253"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: clawsec-advisory-guardian
|
||||
description: Detect advisory matches for installed skills and require explicit user approval before any removal action.
|
||||
metadata: { "openclaw": { "events": ["agent:bootstrap", "command:new"] } }
|
||||
---
|
||||
|
||||
# ClawSec Advisory Guardian Hook
|
||||
|
||||
This hook checks the ClawSec advisory feed against locally installed skills on:
|
||||
|
||||
- `agent:bootstrap`
|
||||
- `command:new`
|
||||
|
||||
When it detects an advisory affecting an installed skill, it posts an alert message.
|
||||
If the advisory looks malicious or removal-oriented, it explicitly recommends removal
|
||||
and asks for user approval first.
|
||||
|
||||
## Safety Contract
|
||||
|
||||
- The hook does not delete or modify skills.
|
||||
- It only reports findings and requests explicit approval before removal.
|
||||
- Alerts are deduplicated using `~/.openclaw/clawsec-suite-feed-state.json`.
|
||||
|
||||
## Optional Environment Variables
|
||||
|
||||
- `CLAWSEC_FEED_URL`: override remote feed URL.
|
||||
- `CLAWSEC_FEED_SIG_URL`: override detached remote feed signature URL (default `${CLAWSEC_FEED_URL}.sig`).
|
||||
- `CLAWSEC_FEED_CHECKSUMS_URL`: override remote checksum manifest URL (default sibling `checksums.json`).
|
||||
- `CLAWSEC_FEED_CHECKSUMS_SIG_URL`: override detached remote checksum manifest signature URL.
|
||||
- `CLAWSEC_FEED_PUBLIC_KEY`: path to pinned feed-signing public key PEM.
|
||||
- `CLAWSEC_LOCAL_FEED`: override local fallback feed file.
|
||||
- `CLAWSEC_LOCAL_FEED_SIG`: override local detached feed signature path.
|
||||
- `CLAWSEC_LOCAL_FEED_CHECKSUMS`: override local checksum manifest path.
|
||||
- `CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG`: override local checksum manifest signature path.
|
||||
- `CLAWSEC_VERIFY_CHECKSUM_MANIFEST`: set to `0` only for emergency troubleshooting (default verifies checksums).
|
||||
- `CLAWSEC_ALLOW_UNSIGNED_FEED`: set to `1` only for temporary migration compatibility; bypasses signature/checksum verification.
|
||||
- `CLAWSEC_SUITE_STATE_FILE`: override state file path.
|
||||
- `CLAWSEC_INSTALL_ROOT`: override installed skills root.
|
||||
- `CLAWSEC_SUITE_DIR`: override clawsec-suite install path.
|
||||
- `CLAWSEC_HOOK_INTERVAL_SECONDS`: minimum interval between hook scans (default `300`).
|
||||
@@ -0,0 +1,253 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { uniqueStrings, resolveConfiguredPath } from "./lib/utils.mjs";
|
||||
import { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.mjs";
|
||||
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
|
||||
import { loadState, persistState } from "./lib/state.ts";
|
||||
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
|
||||
import { loadAdvisorySuppression, isAdvisorySuppressed } from "./lib/suppression.mjs";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://clawsec.prompt.security/advisories/feed.json";
|
||||
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
||||
let unsignedModeWarningShown = false;
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toEventName(event: HookEvent): string {
|
||||
const eventType = String(event.type ?? "").trim();
|
||||
const action = String(event.action ?? "").trim();
|
||||
if (!eventType || !action) return "";
|
||||
return `${eventType}:${action}`;
|
||||
}
|
||||
|
||||
function shouldHandleEvent(event: HookEvent): boolean {
|
||||
const eventName = toEventName(event);
|
||||
return eventName === "agent:bootstrap" || eventName === "command:new";
|
||||
}
|
||||
|
||||
function epochMs(isoTimestamp: string | null): number {
|
||||
if (!isoTimestamp) return 0;
|
||||
const parsed = Date.parse(isoTimestamp);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function scannedRecently(lastScan: string | null, minIntervalSeconds: number): boolean {
|
||||
const sinceMs = Date.now() - epochMs(lastScan);
|
||||
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
|
||||
}
|
||||
|
||||
function configuredPath(
|
||||
explicit: string | undefined,
|
||||
fallback: string,
|
||||
label: string,
|
||||
): string {
|
||||
return resolveConfiguredPath(explicit, fallback, {
|
||||
label,
|
||||
onInvalid: (error, rawValue) => {
|
||||
console.warn(
|
||||
`[clawsec-advisory-guardian] invalid ${label} path "${rawValue}", using default "${fallback}": ${String(error)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFeed(options: {
|
||||
feedUrl: string;
|
||||
feedSignatureUrl: string;
|
||||
feedChecksumsUrl: string;
|
||||
feedChecksumsSignatureUrl: string;
|
||||
localFeedPath: string;
|
||||
localFeedSignaturePath: string;
|
||||
localFeedChecksumsPath: string;
|
||||
localFeedChecksumsSignaturePath: string;
|
||||
feedPublicKeyPath: string;
|
||||
allowUnsigned: boolean;
|
||||
verifyChecksumManifest: boolean;
|
||||
}): Promise<FeedPayload> {
|
||||
const publicKeyPem = options.allowUnsigned ? "" : await fs.readFile(options.feedPublicKeyPath, "utf8");
|
||||
|
||||
const remoteFeed = await loadRemoteFeed(options.feedUrl, {
|
||||
signatureUrl: options.feedSignatureUrl,
|
||||
checksumsUrl: options.feedChecksumsUrl,
|
||||
checksumsSignatureUrl: options.feedChecksumsSignatureUrl,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned: options.allowUnsigned,
|
||||
verifyChecksumManifest: options.verifyChecksumManifest,
|
||||
});
|
||||
if (remoteFeed) return remoteFeed;
|
||||
|
||||
return await loadLocalFeed(options.localFeedPath, {
|
||||
signaturePath: options.localFeedSignaturePath,
|
||||
checksumsPath: options.localFeedChecksumsPath,
|
||||
checksumsSignaturePath: options.localFeedChecksumsSignaturePath,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned: options.allowUnsigned,
|
||||
verifyChecksumManifest: options.verifyChecksumManifest,
|
||||
checksumPublicKeyEntry: path.basename(options.feedPublicKeyPath),
|
||||
});
|
||||
}
|
||||
|
||||
const handler = async (event: HookEvent): Promise<void> => {
|
||||
if (!shouldHandleEvent(event)) return;
|
||||
|
||||
const installRoot = configuredPath(
|
||||
process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT,
|
||||
path.join(os.homedir(), ".openclaw", "skills"),
|
||||
"CLAWSEC_INSTALL_ROOT",
|
||||
);
|
||||
const suiteDir = configuredPath(
|
||||
process.env.CLAWSEC_SUITE_DIR,
|
||||
path.join(installRoot, "clawsec-suite"),
|
||||
"CLAWSEC_SUITE_DIR",
|
||||
);
|
||||
const localFeedPath = configuredPath(
|
||||
process.env.CLAWSEC_LOCAL_FEED,
|
||||
path.join(suiteDir, "advisories", "feed.json"),
|
||||
"CLAWSEC_LOCAL_FEED",
|
||||
);
|
||||
const localFeedSignaturePath = configuredPath(
|
||||
process.env.CLAWSEC_LOCAL_FEED_SIG,
|
||||
`${localFeedPath}.sig`,
|
||||
"CLAWSEC_LOCAL_FEED_SIG",
|
||||
);
|
||||
const localFeedChecksumsPath = configuredPath(
|
||||
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS,
|
||||
path.join(path.dirname(localFeedPath), "checksums.json"),
|
||||
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
|
||||
);
|
||||
const localFeedChecksumsSignaturePath = configuredPath(
|
||||
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG,
|
||||
`${localFeedChecksumsPath}.sig`,
|
||||
"CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG",
|
||||
);
|
||||
const feedPublicKeyPath = configuredPath(
|
||||
process.env.CLAWSEC_FEED_PUBLIC_KEY,
|
||||
path.join(suiteDir, "advisories", "feed-signing-public.pem"),
|
||||
"CLAWSEC_FEED_PUBLIC_KEY",
|
||||
);
|
||||
const stateFile = configuredPath(
|
||||
process.env.CLAWSEC_SUITE_STATE_FILE,
|
||||
path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
|
||||
"CLAWSEC_SUITE_STATE_FILE",
|
||||
);
|
||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||
const feedChecksumsSignatureUrl =
|
||||
process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||
const scanIntervalSeconds = parsePositiveInteger(
|
||||
process.env.CLAWSEC_HOOK_INTERVAL_SECONDS,
|
||||
DEFAULT_SCAN_INTERVAL_SECONDS,
|
||||
);
|
||||
|
||||
if (allowUnsigned && !unsignedModeWarningShown) {
|
||||
unsignedModeWarningShown = true;
|
||||
console.warn(
|
||||
"[clawsec-advisory-guardian] CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. " +
|
||||
"This bypass is temporary migration compatibility and should be removed as soon as signed feed artifacts are available.",
|
||||
);
|
||||
}
|
||||
|
||||
const forceScan = toEventName(event) === "command:new";
|
||||
const state = await loadState(stateFile);
|
||||
if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let feed: FeedPayload;
|
||||
try {
|
||||
feed = await loadFeed({
|
||||
feedUrl,
|
||||
feedSignatureUrl,
|
||||
feedChecksumsUrl,
|
||||
feedChecksumsSignatureUrl,
|
||||
localFeedPath,
|
||||
localFeedSignaturePath,
|
||||
localFeedChecksumsPath,
|
||||
localFeedChecksumsSignaturePath,
|
||||
feedPublicKeyPath,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
state.last_hook_scan = nowIso;
|
||||
state.last_feed_check = nowIso;
|
||||
|
||||
if (typeof feed.updated === "string" && feed.updated.trim()) {
|
||||
state.last_feed_updated = feed.updated;
|
||||
}
|
||||
|
||||
const advisoryIds = feed.advisories
|
||||
.map((advisory) => advisory.id)
|
||||
.filter((id): id is string => typeof id === "string" && id.trim() !== "");
|
||||
state.known_advisories = uniqueStrings([...state.known_advisories, ...advisoryIds]);
|
||||
|
||||
const installedSkills = await discoverInstalledSkills(installRoot);
|
||||
const allMatches = findMatches(feed, installedSkills);
|
||||
|
||||
if (allMatches.length === 0) {
|
||||
await persistState(stateFile, state);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load advisory suppression config (sentinel-gated: requires enabledFor: ["advisory"])
|
||||
let suppressionConfig;
|
||||
try {
|
||||
suppressionConfig = await loadAdvisorySuppression();
|
||||
} catch (err) {
|
||||
console.warn(`[clawsec-advisory-guardian] failed to load suppression config: ${String(err)}`);
|
||||
suppressionConfig = { suppressions: [], enabledFor: [], source: "none" };
|
||||
}
|
||||
|
||||
// Partition matches into active and suppressed
|
||||
const matches: AdvisoryMatch[] = [];
|
||||
const suppressedMatches: AdvisoryMatch[] = [];
|
||||
for (const match of allMatches) {
|
||||
if (isAdvisorySuppressed(match, suppressionConfig.suppressions)) {
|
||||
suppressedMatches.push(match);
|
||||
} else {
|
||||
matches.push(match);
|
||||
}
|
||||
}
|
||||
|
||||
const unseenMatches: AdvisoryMatch[] = [];
|
||||
for (const match of matches) {
|
||||
const key = matchKey(match);
|
||||
if (state.notified_matches[key]) {
|
||||
continue;
|
||||
}
|
||||
unseenMatches.push(match);
|
||||
state.notified_matches[key] = nowIso;
|
||||
}
|
||||
|
||||
if (unseenMatches.length > 0 && Array.isArray(event.messages)) {
|
||||
event.messages.push(buildAlertMessage(unseenMatches, installRoot));
|
||||
}
|
||||
|
||||
if (suppressedMatches.length > 0 && Array.isArray(event.messages)) {
|
||||
event.messages.push(
|
||||
`[clawsec-advisory-guardian] ${suppressedMatches.length} advisory match(es) suppressed by allowlist config.`,
|
||||
);
|
||||
}
|
||||
|
||||
await persistState(stateFile, state);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,48 @@
|
||||
const ADVISORY_APPLICATION_OPENCLAW = "openclaw";
|
||||
const ADVISORY_APPLICATION_ALL = "all";
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function normalizeApplicationValue(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((entry) => typeof entry === "string")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an advisory should be considered by OpenClaw-facing flows.
|
||||
*
|
||||
* Backward compatibility rule:
|
||||
* - Advisories without `application` remain eligible.
|
||||
*
|
||||
* @param {{ application?: unknown }} advisory
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function advisoryAppliesToOpenclaw(advisory) {
|
||||
const application = advisory?.application;
|
||||
if (application === undefined || application === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const applications = normalizeApplicationValue(application);
|
||||
if (applications.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
applications.includes(ADVISORY_APPLICATION_OPENCLAW) ||
|
||||
applications.includes(ADVISORY_APPLICATION_ALL)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import https from "node:https";
|
||||
import path from "node:path";
|
||||
import { isObject } from "./utils.mjs";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class SecurityPolicyError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "SecurityPolicyError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
|
||||
* @returns {https.Agent}
|
||||
*/
|
||||
function createSecureAgent() {
|
||||
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.
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAllowedDomain(url) {
|
||||
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.
|
||||
* @param {string} url
|
||||
* @param {RequestInit} [options]
|
||||
* @returns {Promise<Response>}
|
||||
* @throws {SecurityPolicyError} If URL is not from an allowed domain
|
||||
*/
|
||||
async function secureFetch(url, options = {}) {
|
||||
// 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 globalThis.fetch(url, {
|
||||
...options,
|
||||
// Attach secure agent for Node.js fetch
|
||||
// @ts-ignore - agent is supported in Node.js fetch
|
||||
agent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawSpecifier
|
||||
* @returns {{ name: string; versionSpec: string } | null}
|
||||
*/
|
||||
export function parseAffectedSpecifier(rawSpecifier) {
|
||||
const specifier = String(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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {raw is import("./types.ts").FeedPayload}
|
||||
*/
|
||||
export function isValidFeedPayload(raw) {
|
||||
if (!isObject(raw)) return false;
|
||||
if (typeof raw.version !== "string" || !raw.version.trim()) return false;
|
||||
if (!Array.isArray(raw.advisories)) return false;
|
||||
|
||||
for (const advisory of raw.advisories) {
|
||||
if (!isObject(advisory)) return false;
|
||||
if (typeof advisory.id !== "string" || !advisory.id.trim()) return false;
|
||||
if (typeof advisory.severity !== "string" || !advisory.severity.trim()) return false;
|
||||
if (!Array.isArray(advisory.affected)) return false;
|
||||
if (!advisory.affected.every((entry) => typeof entry === "string" && entry.trim())) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signatureRaw
|
||||
* @returns {Buffer | null}
|
||||
*/
|
||||
function decodeSignature(signatureRaw) {
|
||||
const trimmed = String(signatureRaw ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let encoded = trimmed;
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (isObject(parsed) && 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} payloadRaw
|
||||
* @param {string} signatureRaw
|
||||
* @param {string} publicKeyPem
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
if (!signature) return false;
|
||||
|
||||
const keyPem = String(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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | Buffer} content
|
||||
* @returns {string}
|
||||
*/
|
||||
function sha256Hex(content) {
|
||||
return crypto.createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function extractSha256Value(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
if (isObject(value) && typeof value.sha256 === "string") {
|
||||
const normalized = value.sha256.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} manifestRaw
|
||||
* @returns {{ schemaVersion: string; algorithm: string; files: Record<string, string> }}
|
||||
*/
|
||||
function parseChecksumsManifest(manifestRaw) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(manifestRaw);
|
||||
} catch {
|
||||
throw new Error("Checksum manifest is not valid JSON");
|
||||
}
|
||||
|
||||
if (!isObject(parsed)) {
|
||||
throw new Error("Checksum manifest must be an object");
|
||||
}
|
||||
|
||||
const algorithmRaw = typeof parsed.algorithm === "string" ? parsed.algorithm.trim().toLowerCase() : "sha256";
|
||||
if (algorithmRaw !== "sha256") {
|
||||
throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || "(empty)"}`);
|
||||
}
|
||||
|
||||
// Support legacy manifest formats:
|
||||
// - New standard: schema_version field
|
||||
// - skill-release.yml: version field (e.g., "0.0.1")
|
||||
// - deploy-pages.yml (pre-fix): generated_at field (e.g., "2026-02-08T...")
|
||||
// - Ultimate fallback: "1"
|
||||
const schemaVersion = (
|
||||
typeof parsed.schema_version === "string" ? parsed.schema_version.trim() :
|
||||
typeof parsed.version === "string" ? parsed.version.trim() :
|
||||
typeof parsed.generated_at === "string" ? parsed.generated_at.trim() :
|
||||
"1"
|
||||
);
|
||||
|
||||
if (!schemaVersion) {
|
||||
throw new Error("Checksum manifest missing schema_version");
|
||||
}
|
||||
|
||||
if (!isObject(parsed.files)) {
|
||||
throw new Error("Checksum manifest missing files object");
|
||||
}
|
||||
|
||||
const files = /** @type {Record<string, string>} */ ({});
|
||||
for (const [key, value] of Object.entries(parsed.files)) {
|
||||
if (!String(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 {
|
||||
schemaVersion,
|
||||
algorithm: algorithmRaw,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} entryName
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeChecksumEntryName(entryName) {
|
||||
return String(entryName ?? "")
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^(?:\.\/)+/, "")
|
||||
.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} files
|
||||
* @param {string} entryName
|
||||
* @returns {{ key: string; digest: string } | null}
|
||||
*/
|
||||
function resolveChecksumManifestEntry(files, entryName) {
|
||||
const normalizedEntry = normalizeChecksumEntryName(entryName);
|
||||
if (!normalizedEntry) return null;
|
||||
|
||||
const directCandidates = [
|
||||
normalizedEntry,
|
||||
path.posix.basename(normalizedEntry),
|
||||
`advisories/${path.posix.basename(normalizedEntry)}`,
|
||||
].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (Object.prototype.hasOwnProperty.call(files, candidate)) {
|
||||
return { key: candidate, digest: files[candidate] };
|
||||
}
|
||||
}
|
||||
|
||||
const basename = path.posix.basename(normalizedEntry);
|
||||
if (!basename) return null;
|
||||
|
||||
const basenameMatches = Object.entries(files).filter(([key]) => {
|
||||
const normalizedKey = normalizeChecksumEntryName(key);
|
||||
return path.posix.basename(normalizedKey) === 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ files: Record<string, string> }} manifest
|
||||
* @param {Record<string, string | Buffer>} expectedEntries
|
||||
*/
|
||||
function verifyChecksums(manifest, expectedEntries) {
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedUrl
|
||||
* @returns {string}
|
||||
*/
|
||||
export function defaultChecksumsUrl(feedUrl) {
|
||||
try {
|
||||
return new URL("checksums.json", feedUrl).toString();
|
||||
} catch {
|
||||
const fallbackBase = String(feedUrl ?? "").replace(/\/?[^/]*$/, "");
|
||||
return `${fallbackBase}/checksums.json`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts the basename from a URL or file path.
|
||||
* @param {string} urlOrPath
|
||||
* @param {string} fallback
|
||||
* @returns {string}
|
||||
*/
|
||||
function safeBasename(urlOrPath, fallback) {
|
||||
try {
|
||||
// Try parsing as URL first
|
||||
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 {
|
||||
// Not a URL, try as path
|
||||
const normalized = String(urlOrPath ?? "").trim();
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
if (lastSlash >= 0 && lastSlash < normalized.length - 1) {
|
||||
return normalized.slice(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Function} fetchFn
|
||||
* @param {string} targetUrl
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function fetchText(fetchFn, targetUrl) {
|
||||
const controller = new globalThis.AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetchFn(targetUrl, {
|
||||
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 {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedPath
|
||||
* @param {{
|
||||
* signaturePath?: string;
|
||||
* checksumsPath?: string;
|
||||
* checksumsSignaturePath?: string;
|
||||
* publicKeyPem?: string;
|
||||
* checksumsPublicKeyPem?: string;
|
||||
* allowUnsigned?: boolean;
|
||||
* verifyChecksumManifest?: boolean;
|
||||
* checksumFeedEntry?: string;
|
||||
* checksumSignatureEntry?: string;
|
||||
* checksumPublicKeyEntry?: string;
|
||||
* }} [options]
|
||||
* @returns {Promise<import("./types.ts").FeedPayload>}
|
||||
*/
|
||||
export async function loadLocalFeed(feedPath, options = {}) {
|
||||
const signaturePath = options.signaturePath ?? `${feedPath}.sig`;
|
||||
const checksumsPath = options.checksumsPath ?? path.join(path.dirname(feedPath), "checksums.json");
|
||||
const checksumsSignaturePath = options.checksumsSignaturePath ?? `${checksumsPath}.sig`;
|
||||
const publicKeyPem = String(options.publicKeyPem ?? "");
|
||||
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
|
||||
const allowUnsigned = options.allowUnsigned === true;
|
||||
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 = options.checksumFeedEntry ?? path.basename(feedPath);
|
||||
const checksumSignatureEntry = options.checksumSignatureEntry ?? path.basename(signaturePath);
|
||||
const expectedEntries = /** @type {Record<string, string>} */ ({
|
||||
[checksumFeedEntry]: payloadRaw,
|
||||
[checksumSignatureEntry]: signatureRaw,
|
||||
});
|
||||
|
||||
if (options.checksumPublicKeyEntry) {
|
||||
expectedEntries[options.checksumPublicKeyEntry] = publicKeyPem;
|
||||
}
|
||||
|
||||
verifyChecksums(checksumsManifest, expectedEntries);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
if (!isValidFeedPayload(payload)) {
|
||||
throw new Error(`Invalid advisory feed format: ${feedPath}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedUrl
|
||||
* @param {{
|
||||
* signatureUrl?: string;
|
||||
* checksumsUrl?: string;
|
||||
* checksumsSignatureUrl?: string;
|
||||
* publicKeyPem?: string;
|
||||
* checksumsPublicKeyPem?: string;
|
||||
* allowUnsigned?: boolean;
|
||||
* verifyChecksumManifest?: boolean;
|
||||
* checksumFeedEntry?: string;
|
||||
* checksumSignatureEntry?: string;
|
||||
* }} [options]
|
||||
* @returns {Promise<import("./types.ts").FeedPayload | null>}
|
||||
*/
|
||||
export async function loadRemoteFeed(feedUrl, options = {}) {
|
||||
// Use secure fetch with TLS 1.2+ enforcement and domain validation
|
||||
const fetchFn = secureFetch;
|
||||
if (typeof fetchFn !== "function") return null;
|
||||
|
||||
const signatureUrl = options.signatureUrl ?? `${feedUrl}.sig`;
|
||||
const checksumsUrl = options.checksumsUrl ?? defaultChecksumsUrl(feedUrl);
|
||||
const checksumsSignatureUrl = options.checksumsSignatureUrl ?? `${checksumsUrl}.sig`;
|
||||
const publicKeyPem = String(options.publicKeyPem ?? "");
|
||||
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
|
||||
const allowUnsigned = options.allowUnsigned === true;
|
||||
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||
|
||||
try {
|
||||
const payloadRaw = await fetchText(fetchFn, feedUrl);
|
||||
if (!payloadRaw) return null;
|
||||
|
||||
if (!allowUnsigned) {
|
||||
const signatureRaw = await fetchText(fetchFn, signatureUrl);
|
||||
if (!signatureRaw) return null;
|
||||
|
||||
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only verify checksums if explicitly requested AND both checksum files are available.
|
||||
// Note: Many upstream workflows (e.g., GitHub raw content) don't publish checksums.json,
|
||||
// so we gracefully skip verification when these files are missing.
|
||||
if (verifyChecksumManifest) {
|
||||
const checksumsRaw = await fetchText(fetchFn, checksumsUrl);
|
||||
const checksumsSignatureRaw = await fetchText(fetchFn, 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);
|
||||
// Derive checksum entry names from actual URLs (supports any filename, not just feed.json)
|
||||
const checksumFeedEntry = options.checksumFeedEntry ?? safeBasename(feedUrl, "feed.json");
|
||||
const checksumSignatureEntry = options.checksumSignatureEntry ?? safeBasename(signatureUrl, "feed.json.sig");
|
||||
verifyChecksums(checksumsManifest, {
|
||||
[checksumFeedEntry]: payloadRaw,
|
||||
[checksumSignatureEntry]: signatureRaw,
|
||||
});
|
||||
}
|
||||
// If checksum files missing: continue without checksum verification
|
||||
// (feed signature was already verified above at line 328)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
if (!isValidFeedPayload(payload)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Security policy violations (invalid URLs, non-HTTPS, disallowed domains) return null
|
||||
// to allow graceful fallback to local feed
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
return null;
|
||||
}
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isObject, normalizeSkillName, uniqueStrings } from "./utils.mjs";
|
||||
import { advisoryAppliesToOpenclaw } from "./advisory_scope.mjs";
|
||||
import { versionMatches } from "./version.mjs";
|
||||
import { parseAffectedSpecifier } from "./feed.mjs";
|
||||
import type { Advisory, FeedPayload, InstalledSkill, AdvisoryMatch } from "./types.ts";
|
||||
|
||||
export async function discoverInstalledSkills(installRoot: string): Promise<InstalledSkill[]> {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(installRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills: InstalledSkill[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const fallbackName = entry.name;
|
||||
const skillDir = path.join(installRoot, entry.name);
|
||||
const skillJsonPath = path.join(skillDir, "skill.json");
|
||||
|
||||
let skillName = fallbackName;
|
||||
let version: string | null = "unknown";
|
||||
|
||||
try {
|
||||
const rawSkillJson = await fs.readFile(skillJsonPath, "utf8");
|
||||
const parsedSkillJson = JSON.parse(rawSkillJson);
|
||||
if (isObject(parsedSkillJson) && typeof parsedSkillJson.name === "string" && parsedSkillJson.name.trim()) {
|
||||
skillName = parsedSkillJson.name.trim();
|
||||
}
|
||||
if (
|
||||
isObject(parsedSkillJson) &&
|
||||
typeof parsedSkillJson.version === "string" &&
|
||||
parsedSkillJson.version.trim()
|
||||
) {
|
||||
version = parsedSkillJson.version.trim();
|
||||
}
|
||||
} catch {
|
||||
// best-effort scan: keep fallback directory name when skill.json is missing or invalid
|
||||
}
|
||||
|
||||
skills.push({ name: skillName, dirName: entry.name, version });
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
export function affectedSpecifierMatchesSkill(rawSpecifier: string, skill: InstalledSkill): boolean {
|
||||
const parsed = parseAffectedSpecifier(rawSpecifier);
|
||||
if (!parsed) return false;
|
||||
|
||||
const specName = normalizeSkillName(parsed.name);
|
||||
const skillName = normalizeSkillName(skill.name);
|
||||
if (specName !== skillName) return false;
|
||||
|
||||
return versionMatches(skill.version, parsed.versionSpec);
|
||||
}
|
||||
|
||||
export function advisoryMatchesSkill(advisory: Advisory, skill: InstalledSkill): string[] {
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
const matches = affected.filter((specifier) => affectedSpecifierMatchesSkill(specifier, skill));
|
||||
return uniqueStrings(matches);
|
||||
}
|
||||
|
||||
export function findMatches(feed: FeedPayload, installedSkills: InstalledSkill[]): AdvisoryMatch[] {
|
||||
const matches: AdvisoryMatch[] = [];
|
||||
|
||||
for (const advisory of feed.advisories) {
|
||||
if (!advisoryAppliesToOpenclaw(advisory)) continue;
|
||||
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
for (const skill of installedSkills) {
|
||||
const matchedAffected = advisoryMatchesSkill(advisory, skill);
|
||||
if (matchedAffected.length === 0) continue;
|
||||
matches.push({ advisory, skill, matchedAffected });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function matchKey(match: AdvisoryMatch): string {
|
||||
const normalizedSkillName = normalizeSkillName(match.skill.name);
|
||||
const version = match.skill.version ?? "unknown";
|
||||
const advisoryId =
|
||||
match.advisory.id ??
|
||||
`${match.advisory.title ?? "untitled"}::${match.advisory.published ?? match.advisory.updated ?? "unknown-ts"}`;
|
||||
return `${advisoryId}::${normalizedSkillName}@${version}`;
|
||||
}
|
||||
|
||||
export function looksMalicious(advisory: Advisory): boolean {
|
||||
const type = String(advisory.type ?? "").toLowerCase();
|
||||
const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase();
|
||||
|
||||
if (type === "malicious_skill" || type === "malicious_plugin") return true;
|
||||
if (/\b(malicious|exfiltrat(e|ion)|backdoor|trojan|credential theft|stealer)\b/.test(combined)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function looksRemovalRecommended(advisory: Advisory): boolean {
|
||||
const combined = `${advisory.action ?? ""} ${advisory.title ?? ""} ${advisory.description ?? ""}`.toLowerCase();
|
||||
return /\b(remove|uninstall|delete|disable|do not use|quarantine)\b/.test(combined);
|
||||
}
|
||||
|
||||
export function buildAlertMessage(matches: AdvisoryMatch[], installRoot: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("CLAWSEC ALERT: advisory feed matches installed skill(s).");
|
||||
lines.push("Affected skill advisories:");
|
||||
|
||||
const MAX_LISTED = 8;
|
||||
for (const match of matches.slice(0, MAX_LISTED)) {
|
||||
const severity = String(match.advisory.severity ?? "unknown").toUpperCase();
|
||||
const advisoryId = match.advisory.id ?? "unknown-id";
|
||||
const version = match.skill.version ?? "unknown";
|
||||
const matched = match.matchedAffected.join(", ");
|
||||
lines.push(
|
||||
`- [${severity}] ${advisoryId} -> ${match.skill.name}@${version}` +
|
||||
(matched ? ` (matched: ${matched})` : ""),
|
||||
);
|
||||
if (match.advisory.action) {
|
||||
lines.push(` Action: ${match.advisory.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > MAX_LISTED) {
|
||||
lines.push(`- ... ${matches.length - MAX_LISTED} additional match(es) not shown`);
|
||||
}
|
||||
|
||||
const removalMatches = matches.filter((entry) => looksMalicious(entry.advisory) || looksRemovalRecommended(entry.advisory));
|
||||
if (removalMatches.length > 0) {
|
||||
const impactedSkills = uniqueStrings(removalMatches.map((entry) => entry.skill.name));
|
||||
const impactedDirs = uniqueStrings(removalMatches.map((entry) => entry.skill.dirName));
|
||||
lines.push("");
|
||||
lines.push("Recommendation: one or more matches indicate potentially malicious or unsafe skills.");
|
||||
lines.push("Best practice: remove or disable affected skills only after explicit user approval.");
|
||||
lines.push(
|
||||
"Double-confirmation policy: treat the install request as first intent and require an additional explicit confirmation with this advisory context.",
|
||||
);
|
||||
lines.push(`Approval needed: ask the user to approve removal of: ${impactedSkills.join(", ")}.`);
|
||||
lines.push("Candidate removal paths:");
|
||||
for (const dir of impactedDirs) {
|
||||
lines.push(`- ${path.join(installRoot, dir)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push("");
|
||||
lines.push("Recommendation: review advisories and update/remove affected skills as directed.");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isObject, uniqueStrings } from "./utils.mjs";
|
||||
import type { AdvisoryState } from "./types.ts";
|
||||
|
||||
export const DEFAULT_STATE: AdvisoryState = {
|
||||
schema_version: "1.1",
|
||||
known_advisories: [],
|
||||
last_feed_check: null,
|
||||
last_feed_updated: null,
|
||||
last_hook_scan: null,
|
||||
notified_matches: {},
|
||||
};
|
||||
|
||||
export function normalizeState(raw: unknown): AdvisoryState {
|
||||
if (!isObject(raw)) {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
|
||||
const knownAdvisories = Array.isArray(raw.known_advisories)
|
||||
? uniqueStrings(raw.known_advisories.filter((value): value is string => typeof value === "string" && value.trim() !== ""))
|
||||
: [];
|
||||
|
||||
const notifiedMatches: Record<string, string> = {};
|
||||
if (isObject(raw.notified_matches)) {
|
||||
for (const [key, value] of Object.entries(raw.notified_matches)) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
notifiedMatches[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: "1.1",
|
||||
known_advisories: knownAdvisories,
|
||||
last_feed_check: typeof raw.last_feed_check === "string" ? raw.last_feed_check : null,
|
||||
last_feed_updated: typeof raw.last_feed_updated === "string" ? raw.last_feed_updated : null,
|
||||
last_hook_scan: typeof raw.last_hook_scan === "string" ? raw.last_hook_scan : null,
|
||||
notified_matches: notifiedMatches,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadState(stateFile: string): Promise<AdvisoryState> {
|
||||
try {
|
||||
const raw = await fs.readFile(stateFile, "utf8");
|
||||
return normalizeState(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistState(stateFile: string, state: AdvisoryState): Promise<void> {
|
||||
const normalized = normalizeState(state);
|
||||
await fs.mkdir(path.dirname(stateFile), { recursive: true });
|
||||
const tmpFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`;
|
||||
await fs.writeFile(tmpFile, `${JSON.stringify(normalized, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.rename(tmpFile, stateFile);
|
||||
try {
|
||||
await fs.chmod(stateFile, 0o600);
|
||||
} catch (err: unknown) {
|
||||
const code = err instanceof Error && "code" in err ? (err as { code: string }).code : undefined;
|
||||
if (code === "ENOTSUP" || code === "EPERM") {
|
||||
console.warn(
|
||||
`Warning: chmod 0600 failed for ${stateFile} (${code}). ` +
|
||||
"File permissions may not be enforced on this platform/filesystem.",
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { isObject, normalizeSkillName, resolveUserPath } from "./utils.mjs";
|
||||
|
||||
const DEFAULT_PRIMARY_PATH = path.join(os.homedir(), ".openclaw", "security-audit.json");
|
||||
const DEFAULT_FALLBACK_PATH = ".clawsec/allowlist.json";
|
||||
|
||||
const EMPTY_CONFIG = Object.freeze({
|
||||
suppressions: [],
|
||||
enabledFor: [],
|
||||
source: "none",
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {unknown} entry
|
||||
* @param {number} index
|
||||
* @param {string} source
|
||||
* @returns {{ checkId: string, skill: string, reason: string, suppressedAt: string }}
|
||||
*/
|
||||
function normalizeRule(entry, index, source) {
|
||||
if (!isObject(entry)) {
|
||||
throw new Error(`Suppression entry at index ${index} in ${source} must be an object`);
|
||||
}
|
||||
|
||||
const checkId = typeof entry.checkId === "string" ? entry.checkId.trim() : "";
|
||||
const skill = typeof entry.skill === "string" ? entry.skill.trim() : "";
|
||||
const reason = typeof entry.reason === "string" ? entry.reason.trim() : "";
|
||||
const suppressedAt = typeof entry.suppressedAt === "string" ? entry.suppressedAt.trim() : "";
|
||||
|
||||
if (!checkId) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: checkId`);
|
||||
if (!skill) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: skill`);
|
||||
if (!reason) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: reason`);
|
||||
if (!suppressedAt) throw new Error(`Suppression entry at index ${index} in ${source} missing required field: suppressedAt`);
|
||||
|
||||
return { checkId, skill, reason, suppressedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @param {string} source
|
||||
* @returns {{ suppressions: Array, enabledFor: string[], source: string }}
|
||||
*/
|
||||
function parseConfig(raw, source) {
|
||||
if (!isObject(raw)) {
|
||||
throw new Error(`Config at ${source} must be a JSON object`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(raw.suppressions)) {
|
||||
throw new Error(`Config at ${source} missing 'suppressions' array`);
|
||||
}
|
||||
|
||||
const suppressions = [];
|
||||
for (let i = 0; i < raw.suppressions.length; i++) {
|
||||
suppressions.push(normalizeRule(raw.suppressions[i], i, source));
|
||||
}
|
||||
|
||||
const enabledFor = Array.isArray(raw.enabledFor)
|
||||
? raw.enabledFor
|
||||
.filter((v) => typeof v === "string" && v.trim() !== "")
|
||||
.map((v) => v.trim().toLowerCase())
|
||||
: [];
|
||||
|
||||
return { suppressions, enabledFor, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} configPath
|
||||
* @returns {Promise<{ suppressions: Array, enabledFor: string[], source: string } | null>}
|
||||
*/
|
||||
async function loadConfigFromPath(configPath) {
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, "utf8");
|
||||
return parseConfig(JSON.parse(raw), configPath);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") return null;
|
||||
if (err.code === "EACCES") throw new Error(`Permission denied reading config: ${configPath}`, { cause: err });
|
||||
if (err instanceof SyntaxError) throw new Error(`Malformed JSON in ${configPath}: ${err.message}`, { cause: err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load advisory suppression config using the same 4-tier path resolution
|
||||
* as the audit watchdog config loader.
|
||||
*
|
||||
* The config file must include "advisory" in its enabledFor sentinel
|
||||
* array for advisory suppression to activate. No CLI flag needed -- the
|
||||
* sentinel in the config file IS the gate.
|
||||
*
|
||||
* @param {string} [configPath] - Optional explicit config file path
|
||||
* @returns {Promise<{ suppressions: Array, enabledFor: string[], source: string }>}
|
||||
*/
|
||||
export async function loadAdvisorySuppression(configPath) {
|
||||
// Priority 1: Explicit path
|
||||
if (configPath) {
|
||||
const resolved = resolveUserPath(configPath, { label: "advisory suppression config path" });
|
||||
const config = await loadConfigFromPath(resolved);
|
||||
if (!config) throw new Error(`Advisory suppression config not found: ${resolved}`);
|
||||
if (!config.enabledFor.includes("advisory")) return { ...EMPTY_CONFIG };
|
||||
return config;
|
||||
}
|
||||
|
||||
// Priority 2: Environment variable
|
||||
const envPath = process.env.OPENCLAW_AUDIT_CONFIG;
|
||||
if (typeof envPath === "string" && envPath.trim()) {
|
||||
const resolved = resolveUserPath(envPath.trim(), { label: "OPENCLAW_AUDIT_CONFIG" });
|
||||
const config = await loadConfigFromPath(resolved);
|
||||
if (config && config.enabledFor.includes("advisory")) return config;
|
||||
return { ...EMPTY_CONFIG };
|
||||
}
|
||||
|
||||
// Priority 3: Primary default path
|
||||
const primary = await loadConfigFromPath(DEFAULT_PRIMARY_PATH);
|
||||
if (primary && primary.enabledFor.includes("advisory")) return primary;
|
||||
|
||||
// Priority 4: Fallback path
|
||||
const fallback = await loadConfigFromPath(DEFAULT_FALLBACK_PATH);
|
||||
if (fallback && fallback.enabledFor.includes("advisory")) return fallback;
|
||||
|
||||
return { ...EMPTY_CONFIG };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an advisory match should be suppressed.
|
||||
*
|
||||
* Matching requires BOTH:
|
||||
* - advisory.id === rule.checkId (exact)
|
||||
* - normalizeSkillName(skill.name) === normalizeSkillName(rule.skill) (case-insensitive)
|
||||
*
|
||||
* @param {{ advisory: { id?: string }, skill: { name: string } }} match
|
||||
* @param {Array<{ checkId: string, skill: string }>} suppressions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAdvisorySuppressed(match, suppressions) {
|
||||
if (!Array.isArray(suppressions) || suppressions.length === 0) return false;
|
||||
|
||||
const advisoryId = match.advisory.id ?? "";
|
||||
const skillName = normalizeSkillName(match.skill.name);
|
||||
|
||||
return suppressions.some(
|
||||
(rule) => rule.checkId === advisoryId && normalizeSkillName(rule.skill) === skillName,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type HookEvent = {
|
||||
type?: string;
|
||||
action?: string;
|
||||
messages?: string[];
|
||||
};
|
||||
|
||||
export type Advisory = {
|
||||
id?: string;
|
||||
severity?: string;
|
||||
type?: string;
|
||||
application?: string | string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
affected?: string[];
|
||||
};
|
||||
|
||||
export type FeedPayload = {
|
||||
version: string;
|
||||
updated?: string;
|
||||
advisories: Advisory[];
|
||||
};
|
||||
|
||||
export type InstalledSkill = {
|
||||
name: string;
|
||||
dirName: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type AdvisoryMatch = {
|
||||
advisory: Advisory;
|
||||
skill: InstalledSkill;
|
||||
matchedAffected: string[];
|
||||
};
|
||||
|
||||
export type AdvisoryState = {
|
||||
schema_version: string;
|
||||
known_advisories: string[];
|
||||
last_feed_check: string | null;
|
||||
last_feed_updated: string | null;
|
||||
last_hook_scan: string | null;
|
||||
notified_matches: Record<string, string>;
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is Record<string, unknown>}
|
||||
*/
|
||||
export function isObject(value) {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeSkillName(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} values
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function uniqueStrings(values) {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
function detectHomeDirectory(env = process.env) {
|
||||
if (typeof env.HOME === "string" && env.HOME.trim()) return env.HOME.trim();
|
||||
if (typeof env.USERPROFILE === "string" && env.USERPROFILE.trim()) return env.USERPROFILE.trim();
|
||||
if (
|
||||
typeof env.HOMEDRIVE === "string" &&
|
||||
env.HOMEDRIVE.trim() &&
|
||||
typeof env.HOMEPATH === "string" &&
|
||||
env.HOMEPATH.trim()
|
||||
) {
|
||||
return `${env.HOMEDRIVE.trim()}${env.HOMEPATH.trim()}`;
|
||||
}
|
||||
return os.homedir();
|
||||
}
|
||||
|
||||
const UNEXPANDED_HOME_TOKEN_PATTERN =
|
||||
/(?:^|[\\/])(?:\\?\$HOME|\\?\$\{HOME\}|\\?\$USERPROFILE|\\?\$\{USERPROFILE\}|%HOME%|%USERPROFILE%|\$env:HOME|\$env:USERPROFILE)(?:$|[\\/])/i;
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function expandKnownHomeTokens(value) {
|
||||
const homeDir = detectHomeDirectory(process.env);
|
||||
if (!homeDir) return value;
|
||||
|
||||
let expanded = String(value ?? "");
|
||||
|
||||
if (expanded === "~") {
|
||||
expanded = homeDir;
|
||||
} else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
|
||||
expanded = path.join(homeDir, expanded.slice(2));
|
||||
}
|
||||
|
||||
expanded = expanded
|
||||
.replace(/(?<!\\)\$\{HOME\}/g, homeDir)
|
||||
.replace(/(?<!\\)\$HOME(?=$|[\\/])/g, homeDir)
|
||||
.replace(/(?<!\\)\$\{USERPROFILE\}/gi, homeDir)
|
||||
.replace(/(?<!\\)\$USERPROFILE(?=$|[\\/])/gi, homeDir)
|
||||
.replace(/%HOME%/gi, homeDir)
|
||||
.replace(/%USERPROFILE%/gi, homeDir)
|
||||
.replace(/(?<!\\)\$env:HOME/gi, homeDir)
|
||||
.replace(/(?<!\\)\$env:USERPROFILE/gi, homeDir);
|
||||
|
||||
return expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasUnexpandedHomeToken(value) {
|
||||
return UNEXPANDED_HOME_TOKEN_PATTERN.test(String(value ?? "").trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand `~` and known home env var patterns in user-provided path-like strings.
|
||||
* Also fails fast when unresolved home tokens remain.
|
||||
*
|
||||
* @param {string} inputPath
|
||||
* @param {{label?: string}} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveUserPath(inputPath, { label = "path" } = {}) {
|
||||
const raw = String(inputPath ?? "").trim();
|
||||
if (!raw) return raw;
|
||||
|
||||
const expanded = expandKnownHomeTokens(raw);
|
||||
const normalized = path.normalize(expanded);
|
||||
|
||||
if (hasUnexpandedHomeToken(normalized)) {
|
||||
throw new Error(
|
||||
`Unexpanded home token detected in ${label}: ${raw}. ` +
|
||||
"Use an absolute path or an unquoted home-path expression.",
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an optional explicit path; if invalid, fall back to a default path.
|
||||
*
|
||||
* @param {string | undefined} explicitPath
|
||||
* @param {string} fallbackPath
|
||||
* @param {{label?: string, onInvalid?: (error: unknown, rawValue: string) => void}} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveConfiguredPath(
|
||||
explicitPath,
|
||||
fallbackPath,
|
||||
{ label = "path", onInvalid } = {},
|
||||
) {
|
||||
const explicit = typeof explicitPath === "string" ? explicitPath.trim() : "";
|
||||
if (!explicit) {
|
||||
return resolveUserPath(fallbackPath, { label });
|
||||
}
|
||||
|
||||
try {
|
||||
return resolveUserPath(explicit, { label });
|
||||
} catch (error) {
|
||||
if (typeof onInvalid === "function") {
|
||||
onInvalid(error, explicit);
|
||||
}
|
||||
return resolveUserPath(fallbackPath, { label });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @param {string} version
|
||||
* @returns {[number, number, number] | null}
|
||||
*/
|
||||
export function parseSemver(version) {
|
||||
const cleaned = String(version ?? "")
|
||||
.trim()
|
||||
.replace(/^v/i, "")
|
||||
.split("-")[0];
|
||||
const parts = cleaned.split(".");
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const normalized = parts.slice(0, 3).map((part) => Number.parseInt(part, 10));
|
||||
while (normalized.length < 3) {
|
||||
normalized.push(0);
|
||||
}
|
||||
|
||||
if (normalized.some((part) => Number.isNaN(part))) {
|
||||
return null;
|
||||
}
|
||||
return /** @type {[number, number, number]} */ (normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} left
|
||||
* @param {string} right
|
||||
* @returns {number | null}
|
||||
*/
|
||||
export function compareSemver(left, right) {
|
||||
const a = parseSemver(left);
|
||||
const b = parseSemver(right);
|
||||
if (!a || !b) return null;
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a[index] > b[index]) return 1;
|
||||
if (a[index] < b[index]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeRegex(value) {
|
||||
return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} version
|
||||
* @param {string} rawSpec
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function versionMatches(version, rawSpec) {
|
||||
const spec = String(rawSpec ?? "").trim();
|
||||
if (!spec || spec === "*" || spec.toLowerCase() === "any") return true;
|
||||
if (!version || String(version).trim().toLowerCase() === "unknown") return false;
|
||||
|
||||
const normalizedVersion = String(version).trim().replace(/^v/i, "");
|
||||
|
||||
if (spec.includes("*")) {
|
||||
const regex = new RegExp(`^${escapeRegex(spec).replace(/\\\*/g, ".*")}$`);
|
||||
return regex.test(normalizedVersion);
|
||||
}
|
||||
|
||||
const comparatorMatch = spec.match(/^(>=|<=|>|<|=)\s*(.+)$/);
|
||||
if (comparatorMatch) {
|
||||
const operator = comparatorMatch[1];
|
||||
const targetVersion = comparatorMatch[2].trim();
|
||||
const compared = compareSemver(normalizedVersion, targetVersion);
|
||||
if (compared === null) return false;
|
||||
if (operator === ">=") return compared >= 0;
|
||||
if (operator === "<=") return compared <= 0;
|
||||
if (operator === ">") return compared > 0;
|
||||
if (operator === "<") return compared < 0;
|
||||
return compared === 0;
|
||||
}
|
||||
|
||||
if (spec.startsWith("^")) {
|
||||
const target = parseSemver(spec.slice(1));
|
||||
const current = parseSemver(normalizedVersion);
|
||||
if (!target || !current) return false;
|
||||
if (current[0] !== target[0]) return false;
|
||||
if (target[0] === 0 && current[1] !== target[1]) return false;
|
||||
return compareSemver(normalizedVersion, spec.slice(1)) !== -1;
|
||||
}
|
||||
|
||||
if (spec.startsWith("~")) {
|
||||
const target = parseSemver(spec.slice(1));
|
||||
const current = parseSemver(normalizedVersion);
|
||||
if (!target || !current) return false;
|
||||
return (
|
||||
current[0] === target[0] &&
|
||||
current[1] === target[1] &&
|
||||
compareSemver(normalizedVersion, spec.slice(1)) !== -1
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedVersion === spec || normalizedVersion === spec.replace(/^v/i, "");
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULT_INDEX_URL = "https://clawsec.prompt.security/skills/index.json";
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SUITE_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
const SUITE_SKILL_JSON = path.join(SUITE_DIR, "skill.json");
|
||||
|
||||
function isObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeSkillId(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeBoolean(value) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function parseTimeoutMs() {
|
||||
const raw = String(process.env.CLAWSEC_SKILLS_INDEX_TIMEOUT_MS ?? "").trim();
|
||||
if (!raw) return DEFAULT_TIMEOUT_MS;
|
||||
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
json: false,
|
||||
};
|
||||
|
||||
for (const token of argv) {
|
||||
if (token === "--json") {
|
||||
args.json = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--help" || token === "-h") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/discover_skill_catalog.mjs [--json]",
|
||||
"",
|
||||
"Behavior:",
|
||||
" - Fetches dynamic catalog from CLAWSEC_SKILLS_INDEX_URL (default: https://clawsec.prompt.security/skills/index.json)",
|
||||
" - Falls back to suite-local catalog metadata in skill.json when remote index is unavailable/invalid",
|
||||
"",
|
||||
"Environment:",
|
||||
" CLAWSEC_SKILLS_INDEX_URL Override remote catalog index URL",
|
||||
" CLAWSEC_SKILLS_INDEX_TIMEOUT_MS HTTP timeout in milliseconds (default: 5000)",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRemoteSkills(payload) {
|
||||
if (!isObject(payload)) {
|
||||
throw new Error("Catalog index payload must be a JSON object");
|
||||
}
|
||||
|
||||
const rawSkills = payload.skills;
|
||||
if (!Array.isArray(rawSkills)) {
|
||||
throw new Error("Catalog index missing skills array");
|
||||
}
|
||||
|
||||
const dedup = new Map();
|
||||
|
||||
for (const entry of rawSkills) {
|
||||
if (!isObject(entry)) continue;
|
||||
|
||||
const id = normalizeSkillId(entry.id ?? entry.name);
|
||||
if (!id) continue;
|
||||
|
||||
dedup.set(id, {
|
||||
id,
|
||||
name: String(entry.name ?? id),
|
||||
version: String(entry.version ?? "").trim() || null,
|
||||
description: String(entry.description ?? "").trim() || null,
|
||||
emoji: String(entry.emoji ?? "").trim() || null,
|
||||
category: String(entry.category ?? "").trim() || null,
|
||||
tag: String(entry.tag ?? "").trim() || null,
|
||||
trust: entry.trust ?? null,
|
||||
source: "remote",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: String(payload.version ?? "").trim() || null,
|
||||
updated: String(payload.updated ?? "").trim() || null,
|
||||
skills: [...dedup.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFallbackCatalog() {
|
||||
const raw = await fs.readFile(SUITE_SKILL_JSON, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
const catalogSkills = isObject(parsed?.catalog?.skills) ? parsed.catalog.skills : {};
|
||||
const dedup = new Map();
|
||||
|
||||
for (const [rawId, meta] of Object.entries(catalogSkills)) {
|
||||
const id = normalizeSkillId(rawId);
|
||||
if (!id) continue;
|
||||
|
||||
const safeMeta = isObject(meta) ? meta : {};
|
||||
|
||||
dedup.set(id, {
|
||||
id,
|
||||
name: id,
|
||||
version: null,
|
||||
description: String(safeMeta.description ?? "").trim() || null,
|
||||
emoji: null,
|
||||
category: null,
|
||||
tag: null,
|
||||
trust: null,
|
||||
source: "fallback",
|
||||
integrated_in_suite: normalizeBoolean(safeMeta.integrated_in_suite),
|
||||
requires_explicit_consent: normalizeBoolean(safeMeta.requires_explicit_consent),
|
||||
default_install: normalizeBoolean(safeMeta.default_install),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: null,
|
||||
updated: null,
|
||||
skills: [...dedup.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeWithFallbackMetadata(remoteSkills, fallbackSkills) {
|
||||
const fallbackById = new Map(fallbackSkills.map((skill) => [skill.id, skill]));
|
||||
|
||||
return remoteSkills.map((skill) => {
|
||||
const fallback = fallbackById.get(skill.id);
|
||||
if (!fallback) {
|
||||
return {
|
||||
...skill,
|
||||
integrated_in_suite: false,
|
||||
requires_explicit_consent: false,
|
||||
default_install: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...skill,
|
||||
description: skill.description || fallback.description || null,
|
||||
integrated_in_suite: normalizeBoolean(fallback.integrated_in_suite),
|
||||
requires_explicit_consent: normalizeBoolean(fallback.requires_explicit_consent),
|
||||
default_install: normalizeBoolean(fallback.default_install),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRemoteCatalog(indexUrl, timeoutMs) {
|
||||
if (typeof globalThis.fetch !== "function") {
|
||||
throw new Error("fetch is unavailable in this runtime");
|
||||
}
|
||||
if (typeof globalThis.AbortController !== "function") {
|
||||
throw new Error("AbortController is unavailable in this runtime");
|
||||
}
|
||||
|
||||
const controller = new globalThis.AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await globalThis.fetch(indexUrl, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} while fetching catalog`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return normalizeRemoteSkills(payload);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFlags(skill) {
|
||||
const flags = [];
|
||||
|
||||
if (skill.id === "clawsec-suite") {
|
||||
flags.push("this suite");
|
||||
}
|
||||
if (skill.integrated_in_suite) {
|
||||
flags.push("already integrated in suite");
|
||||
}
|
||||
if (skill.requires_explicit_consent) {
|
||||
flags.push("explicit opt-in");
|
||||
}
|
||||
if (skill.default_install) {
|
||||
flags.push("recommended default");
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function printHumanSummary(result) {
|
||||
process.stdout.write("=== ClawSec Skill Catalog Discovery ===\n");
|
||||
process.stdout.write(`Source: ${result.source}\n`);
|
||||
process.stdout.write(`Index URL: ${result.index_url}\n`);
|
||||
if (result.updated) {
|
||||
process.stdout.write(`Catalog updated: ${result.updated}\n`);
|
||||
}
|
||||
if (result.warning) {
|
||||
process.stdout.write(`Fallback reason: ${result.warning}\n`);
|
||||
}
|
||||
|
||||
process.stdout.write("\nAvailable installable skills:\n");
|
||||
|
||||
if (!Array.isArray(result.skills) || result.skills.length === 0) {
|
||||
process.stdout.write("- none\n");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const skill of result.skills) {
|
||||
const label = skill.version ? `${skill.id} (v${skill.version})` : skill.id;
|
||||
process.stdout.write(`- ${label}\n`);
|
||||
if (skill.description) {
|
||||
process.stdout.write(` ${skill.description}\n`);
|
||||
}
|
||||
|
||||
const flags = formatFlags(skill);
|
||||
if (flags.length > 0) {
|
||||
process.stdout.write(` notes: ${flags.join("; ")}\n`);
|
||||
}
|
||||
|
||||
process.stdout.write(` install: npx clawhub@latest install ${skill.id}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverCatalog() {
|
||||
const indexUrl = process.env.CLAWSEC_SKILLS_INDEX_URL || DEFAULT_INDEX_URL;
|
||||
const timeoutMs = parseTimeoutMs();
|
||||
const fallback = await loadFallbackCatalog();
|
||||
|
||||
try {
|
||||
const remote = await loadRemoteCatalog(indexUrl, timeoutMs);
|
||||
|
||||
return {
|
||||
source: "remote",
|
||||
index_url: indexUrl,
|
||||
version: remote.version,
|
||||
updated: remote.updated,
|
||||
skills: mergeWithFallbackMetadata(remote.skills, fallback.skills),
|
||||
warning: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
source: "fallback",
|
||||
index_url: indexUrl,
|
||||
version: fallback.version,
|
||||
updated: fallback.updated,
|
||||
skills: fallback.skills,
|
||||
warning: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const result = await discoverCatalog();
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
printHumanSummary(result);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_FILES = ["feed-signing-public.pem", "feed.json", "feed.json.sig"];
|
||||
|
||||
function usage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/generate_checksums_json.mjs --out advisories/checksums.json [--base advisories] [--file feed.json --file feed.json.sig ...]",
|
||||
"",
|
||||
"Defaults:",
|
||||
" --base <dirname(--out)>",
|
||||
` --file ${DEFAULT_FILES.join(" --file ")}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = { files: [] };
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--out") {
|
||||
parsed.outPath = argv[++i];
|
||||
} else if (token === "--base") {
|
||||
parsed.baseDir = argv[++i];
|
||||
} else if (token === "--file") {
|
||||
parsed.files.push(argv[++i]);
|
||||
} else if (token === "-h" || token === "--help") {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256Hex(buffer) {
|
||||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { outPath, baseDir, files, help } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!outPath) {
|
||||
usage();
|
||||
throw new Error("Missing required argument: --out");
|
||||
}
|
||||
|
||||
const resolvedBase = path.resolve(baseDir ?? path.dirname(outPath));
|
||||
const fileList = files.length > 0 ? files : DEFAULT_FILES;
|
||||
|
||||
const checksums = {};
|
||||
|
||||
for (const relativePath of [...fileList].sort((a, b) => a.localeCompare(b))) {
|
||||
const absolutePath = path.resolve(resolvedBase, relativePath);
|
||||
const content = await fs.readFile(absolutePath);
|
||||
checksums[relativePath] = sha256Hex(content);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
schema_version: "1.0",
|
||||
algorithm: "sha256",
|
||||
files: checksums,
|
||||
};
|
||||
|
||||
await fs.writeFile(`${outPath}`, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
process.stdout.write(`Wrote ${outPath}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeSkillName, uniqueStrings, resolveUserPath } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
|
||||
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
|
||||
import {
|
||||
defaultChecksumsUrl,
|
||||
parseAffectedSpecifier,
|
||||
loadLocalFeed,
|
||||
loadRemoteFeed,
|
||||
} from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://clawsec.prompt.security/advisories/feed.json";
|
||||
const DEFAULT_SUITE_DIR = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const DEFAULT_LOCAL_FEED = path.join(DEFAULT_SUITE_DIR, "advisories", "feed.json");
|
||||
const DEFAULT_LOCAL_FEED_SIG = `${DEFAULT_LOCAL_FEED}.sig`;
|
||||
const DEFAULT_LOCAL_FEED_CHECKSUMS = path.join(DEFAULT_SUITE_DIR, "advisories", "checksums.json");
|
||||
const DEFAULT_LOCAL_FEED_CHECKSUMS_SIG = `${DEFAULT_LOCAL_FEED_CHECKSUMS}.sig`;
|
||||
const DEFAULT_FEED_PUBLIC_KEY = path.join(DEFAULT_SUITE_DIR, "advisories", "feed-signing-public.pem");
|
||||
const EXIT_CONFIRM_REQUIRED = 42;
|
||||
|
||||
function envPathOrDefault(name, fallback, label) {
|
||||
const envValue = process.env[name];
|
||||
const candidate = typeof envValue === "string" && envValue.trim() ? envValue.trim() : fallback;
|
||||
return resolveUserPath(candidate, { label });
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/guarded_skill_install.mjs --skill <skill-name> [--version <version>] [--confirm-advisory] [--dry-run]",
|
||||
"",
|
||||
"Examples:",
|
||||
" node scripts/guarded_skill_install.mjs --skill helper-plus --version 1.0.1",
|
||||
" node scripts/guarded_skill_install.mjs --skill helper-plus --version 1.0.1 --confirm-advisory",
|
||||
"",
|
||||
"Exit codes:",
|
||||
" 0 success / no advisory block",
|
||||
" 42 advisory matched and second confirmation is required",
|
||||
" 1 error",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
skill: "",
|
||||
version: "",
|
||||
confirmAdvisory: false,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
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 === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
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");
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(parsed.skill)) {
|
||||
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function affectedSpecifierMatches(specifier, skillName, version) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) return false;
|
||||
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false;
|
||||
return versionMatches(version, parsed.versionSpec);
|
||||
}
|
||||
|
||||
function affectedSpecifierMatchesWithoutVersion(specifier, skillName) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) return false;
|
||||
return normalizeSkillName(parsed.name) === normalizeSkillName(skillName);
|
||||
}
|
||||
|
||||
function advisoryLooksHighRisk(advisory) {
|
||||
const type = String(advisory.type ?? "").toLowerCase();
|
||||
const severity = String(advisory.severity ?? "").toLowerCase();
|
||||
const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase();
|
||||
if (type === "malicious_skill" || type === "malicious_plugin") 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;
|
||||
if (severity === "critical") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadFeed() {
|
||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||
const feedChecksumsSignatureUrl = process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||
const localFeedPath = envPathOrDefault("CLAWSEC_LOCAL_FEED", DEFAULT_LOCAL_FEED, "CLAWSEC_LOCAL_FEED");
|
||||
const localFeedSigPath = envPathOrDefault("CLAWSEC_LOCAL_FEED_SIG", DEFAULT_LOCAL_FEED_SIG, "CLAWSEC_LOCAL_FEED_SIG");
|
||||
const localFeedChecksumsPath = envPathOrDefault(
|
||||
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
|
||||
DEFAULT_LOCAL_FEED_CHECKSUMS,
|
||||
"CLAWSEC_LOCAL_FEED_CHECKSUMS",
|
||||
);
|
||||
const localFeedChecksumsSigPath = envPathOrDefault(
|
||||
"CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG",
|
||||
DEFAULT_LOCAL_FEED_CHECKSUMS_SIG,
|
||||
"CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG",
|
||||
);
|
||||
const feedPublicKeyPath = envPathOrDefault("CLAWSEC_FEED_PUBLIC_KEY", DEFAULT_FEED_PUBLIC_KEY, "CLAWSEC_FEED_PUBLIC_KEY");
|
||||
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||
|
||||
if (allowUnsigned) {
|
||||
process.stderr.write(
|
||||
"WARNING: CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. This temporary migration compatibility bypass should be removed once signed feed artifacts are available.\n",
|
||||
);
|
||||
}
|
||||
|
||||
const publicKeyPem = allowUnsigned ? "" : await fs.readFile(feedPublicKeyPath, "utf8");
|
||||
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl, {
|
||||
signatureUrl: feedSignatureUrl,
|
||||
checksumsUrl: feedChecksumsUrl,
|
||||
checksumsSignatureUrl: feedChecksumsSignatureUrl,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
});
|
||||
if (remoteFeed) return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
||||
|
||||
const localFeed = await loadLocalFeed(localFeedPath, {
|
||||
signaturePath: localFeedSigPath,
|
||||
checksumsPath: localFeedChecksumsPath,
|
||||
checksumsSignaturePath: localFeedChecksumsSigPath,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
checksumPublicKeyEntry: path.basename(feedPublicKeyPath),
|
||||
});
|
||||
return { feed: localFeed, source: `local:${localFeedPath}` };
|
||||
}
|
||||
|
||||
function findMatches(feed, skillName, version) {
|
||||
const advisories = Array.isArray(feed.advisories) ? feed.advisories : [];
|
||||
const matches = [];
|
||||
|
||||
for (const advisory of advisories) {
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
const matchedAffected = uniqueStrings(
|
||||
affected.filter((specifier) =>
|
||||
version
|
||||
? affectedSpecifierMatches(specifier, skillName, version)
|
||||
: affectedSpecifierMatchesWithoutVersion(specifier, skillName),
|
||||
),
|
||||
);
|
||||
|
||||
if (matchedAffected.length > 0) {
|
||||
matches.push({ advisory, matchedAffected });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function printMatches(matches, skillName, version) {
|
||||
process.stdout.write("Advisory matches detected for requested install target.\n");
|
||||
process.stdout.write(`Target: ${skillName}${version ? `@${version}` : ""}\n`);
|
||||
|
||||
for (const entry of matches) {
|
||||
const advisory = entry.advisory;
|
||||
const severity = String(advisory.severity ?? "unknown").toUpperCase();
|
||||
const advisoryId = advisory.id ?? "unknown-id";
|
||||
const title = advisory.title ?? "Untitled advisory";
|
||||
process.stdout.write(`- [${severity}] ${advisoryId}: ${title}\n`);
|
||||
process.stdout.write(` matched: ${entry.matchedAffected.join(", ")}\n`);
|
||||
if (advisory.action) {
|
||||
process.stdout.write(` action: ${advisory.action}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runInstall(skillName, version) {
|
||||
const target = version ? `${skillName}@${version}` : skillName;
|
||||
process.stdout.write(`Install target: ${target}\n`);
|
||||
|
||||
const result = spawnSync("npx", ["clawhub@latest", "install", target], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const { feed, source } = await loadFeed();
|
||||
const matches = findMatches(feed, args.skill, args.version);
|
||||
const highRisk = matches.some((entry) => advisoryLooksHighRisk(entry.advisory));
|
||||
|
||||
process.stdout.write(`Advisory source: ${source}\n`);
|
||||
|
||||
if (!args.version) {
|
||||
process.stdout.write(
|
||||
"No --version provided. Conservatively matching any advisory for the requested skill name.\n",
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
printMatches(matches, args.skill, args.version);
|
||||
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write("Install request recognized as first confirmation.\n");
|
||||
process.stdout.write("Additional explicit confirmation is required with advisory context.\n");
|
||||
|
||||
if (!args.confirmAdvisory) {
|
||||
process.stdout.write(
|
||||
"Re-run with --confirm-advisory to proceed after the user explicitly confirms.\n",
|
||||
);
|
||||
process.exit(EXIT_CONFIRM_REQUIRED);
|
||||
}
|
||||
process.stdout.write("Second confirmation provided via --confirm-advisory.\n");
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
process.stdout.write("Dry run only; install command was not executed.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (highRisk) {
|
||||
process.stdout.write(
|
||||
"High-risk advisory context acknowledged. Proceeding only because --confirm-advisory was provided.\n",
|
||||
);
|
||||
}
|
||||
|
||||
runInstall(args.skill, args.version);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const JOB_NAME = process.env.CLAWSEC_ADVISORY_CRON_NAME?.trim() || "ClawSec Advisory Scan";
|
||||
const JOB_EVERY = process.env.CLAWSEC_ADVISORY_CRON_EVERY?.trim() || "6h";
|
||||
const JOB_DESCRIPTION =
|
||||
"Trigger a periodic ClawSec advisory scan in the main session and ask for approval before removing flagged skills.";
|
||||
const SYSTEM_EVENT =
|
||||
"Run ClawSec advisory scan. If installed skills are flagged as malicious or removal is recommended, notify the user and request explicit approval before any removal.";
|
||||
|
||||
function sh(cmd, args) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const details = (result.stderr || result.stdout || "").trim();
|
||||
throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function requireOpenClawCli() {
|
||||
try {
|
||||
sh("openclaw", ["--version"]);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
|
||||
`Original error: ${String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingJobId(jobsPayload) {
|
||||
if (!jobsPayload || !Array.isArray(jobsPayload.jobs)) return null;
|
||||
const existing = jobsPayload.jobs.find((job) => job && job.name === JOB_NAME);
|
||||
return existing?.id ?? null;
|
||||
}
|
||||
|
||||
function addJob() {
|
||||
const out = sh("openclaw", [
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
JOB_NAME,
|
||||
"--description",
|
||||
JOB_DESCRIPTION,
|
||||
"--every",
|
||||
JOB_EVERY,
|
||||
"--session",
|
||||
"main",
|
||||
"--system-event",
|
||||
SYSTEM_EVENT,
|
||||
"--wake",
|
||||
"now",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(out);
|
||||
return payload?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function editJob(jobId) {
|
||||
sh("openclaw", [
|
||||
"cron",
|
||||
"edit",
|
||||
jobId,
|
||||
"--name",
|
||||
JOB_NAME,
|
||||
"--description",
|
||||
JOB_DESCRIPTION,
|
||||
"--enable",
|
||||
"--every",
|
||||
JOB_EVERY,
|
||||
"--session",
|
||||
"main",
|
||||
"--system-event",
|
||||
SYSTEM_EVENT,
|
||||
"--wake",
|
||||
"now",
|
||||
]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
requireOpenClawCli();
|
||||
|
||||
const jobsOut = sh("openclaw", ["cron", "list", "--json"]);
|
||||
const jobsPayload = JSON.parse(jobsOut);
|
||||
const existingJobId = findExistingJobId(jobsPayload);
|
||||
|
||||
if (existingJobId) {
|
||||
editJob(existingJobId);
|
||||
process.stdout.write(`Updated cron job ${existingJobId}: ${JOB_NAME}\n`);
|
||||
} else {
|
||||
const createdId = addJob();
|
||||
if (createdId) {
|
||||
process.stdout.write(`Created cron job ${createdId}: ${JOB_NAME}\n`);
|
||||
} else {
|
||||
process.stdout.write(`Created cron job: ${JOB_NAME}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Schedule: every ${JOB_EVERY}\n`);
|
||||
process.stdout.write("Session target: main (system event + wake now)\n");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HOOK_NAME = "clawsec-advisory-guardian";
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SUITE_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
const SOURCE_HOOK_DIR = path.join(SUITE_DIR, "hooks", HOOK_NAME);
|
||||
const HOOKS_ROOT = path.join(os.homedir(), ".openclaw", "hooks");
|
||||
const TARGET_HOOK_DIR = path.join(HOOKS_ROOT, HOOK_NAME);
|
||||
|
||||
function sh(cmd, args) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const details = (result.stderr || result.stdout || "").trim();
|
||||
throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function requireOpenClawCli() {
|
||||
try {
|
||||
sh("openclaw", ["--version"]);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
|
||||
`Original error: ${String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSourceHookExists() {
|
||||
const requiredFiles = [
|
||||
"HOOK.md",
|
||||
"handler.ts",
|
||||
"lib/utils.mjs",
|
||||
"lib/version.mjs",
|
||||
"lib/feed.mjs",
|
||||
];
|
||||
for (const file of requiredFiles) {
|
||||
const fullPath = path.join(SOURCE_HOOK_DIR, file);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Missing required hook file: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installHookFiles() {
|
||||
fs.mkdirSync(HOOKS_ROOT, { recursive: true });
|
||||
fs.rmSync(TARGET_HOOK_DIR, { recursive: true, force: true });
|
||||
fs.cpSync(SOURCE_HOOK_DIR, TARGET_HOOK_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function enableHook() {
|
||||
sh("openclaw", ["hooks", "enable", HOOK_NAME]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
assertSourceHookExists();
|
||||
requireOpenClawCli();
|
||||
installHookFiles();
|
||||
enableHook();
|
||||
|
||||
process.stdout.write(`Installed hook files to: ${TARGET_HOOK_DIR}\n`);
|
||||
process.stdout.write(`Enabled hook: ${HOOK_NAME}\n`);
|
||||
process.stdout.write("Restart your OpenClaw gateway process so the hook is loaded.\n");
|
||||
process.stdout.write("After restart, run /new once to trigger an immediate advisory scan.\n");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user