mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
Added dynamic skill-catalog discovery in clawsec-suite (#26)
* feat(clawsec-suite): integrate audit-watchdog and add email-gated setup * fix(clawsec-suite): escape shell env assignments in watchdog setup * fix(lint): remove unnecessary escapes in watchdog exec template * clawsec-suite: add dynamic remote skill catalog discovery with fallback * clawsec-suite: align signed feed defaults and checksum key compatibility * fix(lint): use globalThis fetch/AbortController in catalog script * Revert "fix(lint): remove unnecessary escapes in watchdog exec template" This reverts commit 09e40d2a8861e2d179137467c9ba938776609a56. * Revert "fix(clawsec-suite): escape shell env assignments in watchdog setup" This reverts commit 54d97653a6f8ac14c125ef14c59bca7532cfee15. * Revert "feat(clawsec-suite): integrate audit-watchdog and add email-gated setup" This reverts commit 1ba55dd69ecb7a248a53123277158ce27474d5f7. * fix(openclaw-audit-watchdog): escape shell env interpolation in setup_cron * ci(signing): enforce key consistency across docs, repo, and generated assets * docs(readme): document signing key consistency CI guardrails * chore(clawsec-suite): bump to 0.1.0 and record release changelog * chore(changelog): update to version 0.1.1 and enhance signing key drift control documentation * chore(clawsec-suite): bump version to 0.1.1
This commit is contained in:
@@ -25,6 +25,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify signing key consistency (repo + docs)
|
||||
run: ./scripts/ci/verify_signing_key_consistency.sh
|
||||
|
||||
- name: Auto-discover skills from releases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -274,6 +277,18 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -36,6 +36,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify signing key consistency (repo + docs)
|
||||
run: ./scripts/ci/verify_signing_key_consistency.sh
|
||||
|
||||
- name: Validate version parity for bumped skills
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -526,6 +529,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify signing key consistency (repo + docs)
|
||||
run: ./scripts/ci/verify_signing_key_consistency.sh
|
||||
|
||||
- name: Validate skill exists
|
||||
run: |
|
||||
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
|
||||
@@ -782,6 +788,18 @@ jobs:
|
||||
signature_file: release-assets/checksums.sig
|
||||
public_key_output: release-assets/signing-public.pem
|
||||
|
||||
- name: Verify generated release 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 release-assets/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::release-assets/signing-public.pem fingerprint mismatch vs clawsec-signing-public.pem"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Show signed release assets
|
||||
run: |
|
||||
echo "Signed and verified release-assets/checksums.json"
|
||||
|
||||
@@ -78,7 +78,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 |
|
||||
|
||||
@@ -169,10 +169,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. **Releases** - Publishes to GitHub Releases with all artifacts
|
||||
4. **Supersedes Old Releases** - Marks older versions (same major) as pre-releases
|
||||
5. **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
|
||||
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@ As of branch `integration/signing-work`, advisory distribution is **unsigned**:
|
||||
- Feed consumers:
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||
- both default to `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
- 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.
|
||||
|
||||
@@ -37,7 +37,7 @@ This document defines the **target operating model** for signed artifacts while
|
||||
|
||||
### Release artifact channel (recommended)
|
||||
- `<release>/checksums.json`
|
||||
- `<release>/checksums.json.sig`
|
||||
- `<release>/checksums.sig`
|
||||
- `advisories/release-signing-public.pem` (or equivalent repo-pinned location)
|
||||
|
||||
## 4) Key roles and custody
|
||||
@@ -138,7 +138,7 @@ Current release generator:
|
||||
|
||||
Target update:
|
||||
- sign `checksums.json` before `softprops/action-gh-release`
|
||||
- attach `checksums.json.sig` to each release
|
||||
- attach `checksums.sig` to each release
|
||||
|
||||
## 8) Rotation policy and runbook
|
||||
|
||||
|
||||
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"
|
||||
@@ -5,6 +5,30 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -16,7 +16,7 @@ Run this periodically (cron/systemd/CI/agent scheduler). It assumes POSIX shell,
|
||||
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://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.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}"
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-suite
|
||||
version: 0.0.10
|
||||
version: 0.1.1
|
||||
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:
|
||||
@@ -27,11 +27,21 @@ This means `clawsec-suite` can:
|
||||
- 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`
|
||||
|
||||
### installed separately
|
||||
- `openclaw-audit-watchdog`
|
||||
- `soul-guardian`
|
||||
- `clawtributor` (explicit opt-in)
|
||||
### 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
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/discover_skill_catalog.mjs"
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -52,16 +62,14 @@ DEST="$INSTALL_ROOT/clawsec-suite"
|
||||
BASE="https://github.com/prompt-security/clawsec/releases/download/clawsec-suite-v${VERSION}"
|
||||
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
DOWNLOAD_DIR="$TEMP_DIR/downloads"
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
mkdir -p "$DOWNLOAD_DIR"
|
||||
|
||||
# Pinned release-signing public key (verify fingerprint out-of-band on first use)
|
||||
# Fingerprint (SHA-256 of SPKI DER): 35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854
|
||||
RELEASE_PUBKEY_SHA256="35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854"
|
||||
# Fingerprint (SHA-256 of SPKI DER): 711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8
|
||||
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
|
||||
cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM'
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAtaRGONGp0Syl9EBS17hEYgGTwUtfZgigklS6vAe5MlQ=
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
PEM
|
||||
|
||||
@@ -71,70 +79,48 @@ if [ "$ACTUAL_KEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1) Download checksums manifest + detached signature
|
||||
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
|
||||
curl -fsSL "$BASE/checksums.json.sig" -o "$TEMP_DIR/checksums.json.sig"
|
||||
ZIP_NAME="clawsec-suite-v${VERSION}.zip"
|
||||
|
||||
# 2) Verify checksums manifest signature before trusting any file URLs or hashes
|
||||
openssl base64 -d -A -in "$TEMP_DIR/checksums.json.sig" -out "$TEMP_DIR/checksums.json.sig.bin"
|
||||
# 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"
|
||||
|
||||
# 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.json.sig.bin" \
|
||||
-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
|
||||
|
||||
if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: Invalid checksums.json format" >&2
|
||||
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
|
||||
|
||||
echo "Checksums manifest signature verified."
|
||||
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
|
||||
|
||||
# 3) Download every file listed in checksums and verify immediately
|
||||
DOWNLOAD_FAILED=0
|
||||
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
|
||||
FILE_URL="$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")"
|
||||
EXPECTED="$(jq -r --arg f "$file" '.files[$f].sha256' "$TEMP_DIR/checksums.json")"
|
||||
|
||||
if ! curl -fsSL "$FILE_URL" -o "$DOWNLOAD_DIR/$file"; then
|
||||
echo "ERROR: Download failed for $file" >&2
|
||||
DOWNLOAD_FAILED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
ACTUAL="$(shasum -a 256 "$DOWNLOAD_DIR/$file" | awk '{print $1}')"
|
||||
else
|
||||
ACTUAL="$(sha256sum "$DOWNLOAD_DIR/$file" | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "ERROR: Checksum mismatch for $file" >&2
|
||||
DOWNLOAD_FAILED=1
|
||||
else
|
||||
echo "Verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DOWNLOAD_FAILED" -eq 1 ]; then
|
||||
echo "ERROR: One or more files failed verification" >&2
|
||||
if [ "$EXPECTED_ZIP_SHA" != "$ACTUAL_ZIP_SHA" ]; then
|
||||
echo "ERROR: Archive checksum mismatch for $ZIP_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4) Install files using paths from checksums.json
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")"
|
||||
SRC_PATH="$DOWNLOAD_DIR/$file"
|
||||
DST_PATH="$DEST/$REL_PATH"
|
||||
echo "Checksums manifest signature and archive hash verified."
|
||||
|
||||
mkdir -p "$(dirname "$DST_PATH")"
|
||||
cp "$SRC_PATH" "$DST_PATH"
|
||||
done < <(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json")
|
||||
# 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 {} \;
|
||||
@@ -194,7 +180,7 @@ This enforces:
|
||||
|
||||
The embedded feed logic uses these defaults:
|
||||
|
||||
- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
- 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`
|
||||
@@ -204,12 +190,12 @@ The embedded feed logic uses these defaults:
|
||||
- State file: `~/.openclaw/clawsec-suite-feed-state.json`
|
||||
- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`)
|
||||
|
||||
**Fail-closed verification:** Both signature and checksum manifest verification are required by default. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream.
|
||||
**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://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.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}"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
@@ -273,13 +259,20 @@ The suite hook and heartbeat guidance are intentionally non-destructive by defau
|
||||
|
||||
## Optional Skill Installation
|
||||
|
||||
Install additional protections as needed:
|
||||
Discover currently available installable skills dynamically, then install the ones you want:
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install openclaw-audit-watchdog
|
||||
npx clawhub@latest install soul-guardian
|
||||
# opt-in only:
|
||||
npx clawhub@latest install clawtributor
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ import { loadState, persistState } from "./lib/state.ts";
|
||||
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||
"https://clawsec.prompt.security/advisories/feed.json";
|
||||
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
||||
let unsignedModeWarningShown = false;
|
||||
|
||||
|
||||
@@ -273,6 +273,62 @@ function parseChecksumsManifest(manifestRaw) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
@@ -281,14 +337,14 @@ function verifyChecksums(manifest, expectedEntries) {
|
||||
for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
|
||||
if (!entryName) continue;
|
||||
|
||||
const expectedDigest = manifest.files[entryName];
|
||||
if (!expectedDigest) {
|
||||
const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
|
||||
if (!resolved) {
|
||||
throw new Error(`Checksum manifest missing required entry: ${entryName}`);
|
||||
}
|
||||
|
||||
const actualDigest = sha256Hex(entryContent);
|
||||
if (actualDigest !== expectedDigest) {
|
||||
throw new Error(`Checksum mismatch for ${entryName}`);
|
||||
if (actualDigest !== resolved.digest) {
|
||||
throw new Error(`Checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||
"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`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-suite",
|
||||
"version": "0.0.10",
|
||||
"version": "0.1.1",
|
||||
"description": "ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
@@ -120,6 +120,11 @@
|
||||
"required": true,
|
||||
"description": "Two-step confirmation installer with signature verification that blocks risky skill installs"
|
||||
},
|
||||
{
|
||||
"path": "scripts/discover_skill_catalog.mjs",
|
||||
"required": true,
|
||||
"description": "Dynamic skill-catalog discovery with remote index fetch and suite-local fallback metadata"
|
||||
},
|
||||
{
|
||||
"path": "scripts/sign_detached_ed25519.mjs",
|
||||
"required": false,
|
||||
|
||||
@@ -283,6 +283,57 @@ async function testLoadLocalFeed_ValidSignedFeed() {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: loadLocalFeed - supports advisories/* checksum keys
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testLoadLocalFeed_AdvisoriesPrefixedChecksumKeys() {
|
||||
const testName = "loadLocalFeed: advisories/* checksum keys are accepted";
|
||||
try {
|
||||
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||
const feedContent = createValidFeed();
|
||||
const feedSignature = signPayload(feedContent, privateKeyPem);
|
||||
|
||||
const advisoriesDir = path.join(tempDir, "advisories");
|
||||
await fs.mkdir(advisoriesDir, { recursive: true });
|
||||
|
||||
const checksumManifest = createChecksumManifest({
|
||||
"advisories/feed.json": feedContent,
|
||||
"advisories/feed.json.sig": feedSignature + "\n",
|
||||
"advisories/feed-signing-public.pem": publicKeyPem,
|
||||
});
|
||||
const checksumSignature = signPayload(checksumManifest, privateKeyPem);
|
||||
|
||||
const feedPath = path.join(advisoriesDir, "feed.json");
|
||||
const sigPath = path.join(advisoriesDir, "feed.json.sig");
|
||||
const checksumPath = path.join(advisoriesDir, "checksums.json");
|
||||
const checksumSigPath = path.join(advisoriesDir, "checksums.json.sig");
|
||||
const keyPath = path.join(advisoriesDir, "feed-signing-public.pem");
|
||||
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||
await fs.writeFile(checksumPath, checksumManifest);
|
||||
await fs.writeFile(checksumSigPath, checksumSignature + "\n");
|
||||
await fs.writeFile(keyPath, publicKeyPem);
|
||||
|
||||
const feed = await loadLocalFeed(feedPath, {
|
||||
signaturePath: sigPath,
|
||||
checksumsPath: checksumPath,
|
||||
checksumsSignaturePath: checksumSigPath,
|
||||
publicKeyPem,
|
||||
verifyChecksumManifest: true,
|
||||
checksumPublicKeyEntry: path.basename(keyPath),
|
||||
});
|
||||
|
||||
if (feed && feed.version === "1.0.0" && feed.advisories.length === 1) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Feed did not load with advisories/* checksum keys");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: loadLocalFeed - tampered feed fails (fail-closed)
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -542,6 +593,7 @@ async function runTests() {
|
||||
|
||||
// Local feed loading tests
|
||||
await testLoadLocalFeed_ValidSignedFeed();
|
||||
await testLoadLocalFeed_AdvisoriesPrefixedChecksumKeys();
|
||||
await testLoadLocalFeed_TamperedFeedFails();
|
||||
await testLoadLocalFeed_MissingSignatureFails();
|
||||
await testLoadLocalFeed_AllowUnsignedBypasses();
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Dynamic skill catalog discovery tests for clawsec-suite.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Remote index fetch and normalization
|
||||
* - Enrichment with suite-local metadata (non-breaking compatibility)
|
||||
* - Fallback behavior when remote index is invalid/unavailable
|
||||
*
|
||||
* Run: node skills/clawsec-suite/test/skill_catalog_discovery.test.mjs
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "discover_skill_catalog.mjs");
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount += 1;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount += 1;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function runCatalogScript(args, env = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("node", [SCRIPT_PATH, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function withServer(handler) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
reject(new Error("Failed to bind test server"));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
close: () =>
|
||||
new Promise((done) => {
|
||||
server.close(() => done());
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: remote index is used when valid
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testRemoteCatalogSuccess() {
|
||||
const testName = "discover_skill_catalog: uses remote index when valid";
|
||||
let fixture = null;
|
||||
|
||||
try {
|
||||
fixture = await withServer((req, res) => {
|
||||
if (req.url !== "/index.json") {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
version: "1.0.0",
|
||||
updated: "2026-02-16T08:20:00Z",
|
||||
skills: [
|
||||
{
|
||||
id: "soul-guardian",
|
||||
name: "soul-guardian",
|
||||
version: "9.9.9",
|
||||
description: "Remote skill metadata",
|
||||
emoji: "👻",
|
||||
category: "security",
|
||||
tag: "soul-guardian-v9.9.9",
|
||||
},
|
||||
{
|
||||
id: "clawtributor",
|
||||
name: "clawtributor",
|
||||
version: "1.2.3",
|
||||
description: "Remote clawtributor metadata",
|
||||
emoji: "🤝",
|
||||
category: "security",
|
||||
tag: "clawtributor-v1.2.3",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const result = await runCatalogScript(["--json"], {
|
||||
CLAWSEC_SKILLS_INDEX_URL: `${fixture.url}/index.json`,
|
||||
CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "2000",
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
const clawtributor = payload.skills.find((entry) => entry.id === "clawtributor");
|
||||
const soulGuardian = payload.skills.find((entry) => entry.id === "soul-guardian");
|
||||
|
||||
if (
|
||||
payload.source === "remote" &&
|
||||
payload.updated === "2026-02-16T08:20:00Z" &&
|
||||
soulGuardian?.version === "9.9.9" &&
|
||||
clawtributor?.requires_explicit_consent === true
|
||||
) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Unexpected payload: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
if (fixture) {
|
||||
await fixture.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: invalid remote payload falls back to suite-local catalog
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testInvalidRemotePayloadFallsBack() {
|
||||
const testName = "discover_skill_catalog: invalid remote payload falls back";
|
||||
let fixture = null;
|
||||
|
||||
try {
|
||||
fixture = await withServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ version: "1.0.0", note: "missing skills" }));
|
||||
});
|
||||
|
||||
const result = await runCatalogScript(["--json"], {
|
||||
CLAWSEC_SKILLS_INDEX_URL: `${fixture.url}/index.json`,
|
||||
CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "2000",
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
const hasSoulGuardian = Array.isArray(payload.skills)
|
||||
? payload.skills.some((entry) => entry.id === "soul-guardian")
|
||||
: false;
|
||||
|
||||
if (payload.source === "fallback" && hasSoulGuardian && String(payload.warning).includes("skills array")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Unexpected payload: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
} finally {
|
||||
if (fixture) {
|
||||
await fixture.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: unreachable remote index falls back to suite-local catalog
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testUnreachableRemoteFallsBack() {
|
||||
const testName = "discover_skill_catalog: unreachable remote index falls back";
|
||||
|
||||
try {
|
||||
const result = await runCatalogScript(["--json"], {
|
||||
CLAWSEC_SKILLS_INDEX_URL: "http://127.0.0.1:9/index.json",
|
||||
CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "250",
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
if (payload.source === "fallback" && Array.isArray(payload.skills) && payload.skills.length > 0) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Unexpected payload: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main test runner
|
||||
// -----------------------------------------------------------------------------
|
||||
async function runTests() {
|
||||
console.log("=== ClawSec Skill Catalog Discovery Tests ===\n");
|
||||
|
||||
await testRemoteCatalogSuccess();
|
||||
await testInvalidRemotePayloadFallsBack();
|
||||
await testUnreachableRemoteFallsBack();
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -53,7 +53,16 @@ function oneline(v) {
|
||||
return String(v ?? "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/"/g, "\\\"")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeForShellEnvVar(v) {
|
||||
return String(v ?? "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\$/g, "\\$")
|
||||
.replace(/`/g, "\\`")
|
||||
.replace(/"/g, "\\\"")
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -66,7 +75,9 @@ function defaultInstallDir() {
|
||||
}
|
||||
|
||||
function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) {
|
||||
const safeDir = oneline(installDir || "");
|
||||
const safeDir = escapeForShellEnvVar(installDir || "");
|
||||
const escapedHostLabel = escapeForShellEnvVar(hostLabel);
|
||||
|
||||
return [
|
||||
"Run daily openclaw security audits and deliver report (DM + email).",
|
||||
"",
|
||||
@@ -74,7 +85,7 @@ function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) {
|
||||
`Email: ${COMPANY_EMAIL} (local sendmail)`,
|
||||
"",
|
||||
"Execute:",
|
||||
`- Run via exec: cd "${safeDir}" && PROMPTSEC_HOST_LABEL="${oneline(hostLabel)}" ./scripts/runner.sh`,
|
||||
`- Run via exec: cd "${safeDir}" && PROMPTSEC_HOST_LABEL="${escapedHostLabel}" ./scripts/runner.sh`,
|
||||
"",
|
||||
"Output requirements:",
|
||||
"- Print the report to stdout (cron deliver will DM it).",
|
||||
|
||||
Reference in New Issue
Block a user