diff --git a/.github/actions/sign-and-verify/action.yml b/.github/actions/sign-and-verify/action.yml
new file mode 100644
index 0000000..1ba3641
--- /dev/null
+++ b/.github/actions/sign-and-verify/action.yml
@@ -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}"
diff --git a/.github/requirements-lint-python.txt b/.github/requirements-lint-python.txt
new file mode 100644
index 0000000..9c85f4b
--- /dev/null
+++ b/.github/requirements-lint-python.txt
@@ -0,0 +1,2 @@
+ruff==0.6.9
+bandit==1.7.9
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d91fa8a..4378c33 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,8 +11,8 @@ jobs:
name: Lint TypeScript/React
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'
@@ -28,12 +28,14 @@ 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'
+ cache: 'pip'
+ cache-dependency-path: '.github/requirements-lint-python.txt'
- name: Install linters
- run: pip install ruff bandit
+ run: python -m pip install -r .github/requirements-lint-python.txt
- name: Ruff (lint + format check)
run: ruff check utils/ --output-format=github
- name: Bandit (security)
@@ -43,9 +45,9 @@ jobs:
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 +56,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@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
scan-type: 'fs'
scan-ref: '.'
@@ -64,7 +66,7 @@ jobs:
exit-code: '1'
ignore-unfixed: true
- name: Trivy Config Scan
- uses: aquasecurity/trivy-action@master
+ uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
scan-type: 'config'
scan-ref: '.'
@@ -75,8 +77,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 +87,16 @@ 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
diff --git a/.github/workflows/community-advisory.yml b/.github/workflows/community-advisory.yml
index b7a67a0..3ef543f 100644
--- a/.github/workflows/community-advisory.yml
+++ b/.github/workflows/community-advisory.yml
@@ -7,6 +7,7 @@ on:
permissions:
contents: write
issues: write
+ pull-requests: write
concurrency:
group: community-advisory
@@ -14,7 +15,9 @@ concurrency:
env:
FEED_PATH: advisories/feed.json
+ FEED_SIG_PATH: advisories/feed.json.sig
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
+ SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
jobs:
process-advisory:
@@ -22,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
@@ -196,46 +199,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 }}';
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 4aaf3ee..1374b5d 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -23,10 +23,11 @@ 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: Auto-discover skills from releases
env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
@@ -48,17 +49,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 +71,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 +100,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 +125,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 +187,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 +208,105 @@ 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: 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 +328,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 +364,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 +378,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 +395,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
diff --git a/.github/workflows/poll-nvd-cves.yml b/.github/workflows/poll-nvd-cves.yml
index 3d50019..3bee19f 100644
--- a/.github/workflows/poll-nvd-cves.yml
+++ b/.github/workflows/poll-nvd-cves.yml
@@ -22,7 +22,9 @@ concurrency:
env:
FEED_PATH: advisories/feed.json
+ FEED_SIG_PATH: advisories/feed.json.sig
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
+ SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
KEYWORDS: "OpenClaw clawdbot Moltbot"
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
@@ -31,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
@@ -80,6 +82,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 +93,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 +104,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 +128,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/
@@ -508,6 +534,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: |
@@ -518,7 +560,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 }}
@@ -543,7 +585,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: |
diff --git a/.github/workflows/skill-release.yml b/.github/workflows/skill-release.yml
index 631c8f5..27f18dd 100644
--- a/.github/workflows/skill-release.yml
+++ b/.github/workflows/skill-release.yml
@@ -26,7 +26,7 @@ jobs:
contents: read
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -174,10 +174,21 @@ jobs:
contents: read
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+ - name: Generate test signing key for dry-run
+ run: |
+ set -euo pipefail
+ echo "Generating temporary Ed25519 test key for dry-run validation"
+ umask 077
+ mkdir -p /tmp/test-signing
+ # Use Ed25519 to match production signing (not RSA)
+ openssl genpkey -algorithm ED25519 -out /tmp/test-signing/private.pem
+ openssl pkey -in /tmp/test-signing/private.pem -pubout -out /tmp/test-signing/public.pem
+ echo "TEST_SIGNING_KEY_DIR=/tmp/test-signing" >> $GITHUB_ENV
+
- name: Run release dry-run for changed skills
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -185,6 +196,83 @@ jobs:
run: |
set -euo pipefail
+ # Helper function to sign advisory artifacts with test key (dry-run only)
+ sign_advisory_artifacts() {
+ local skill_dir="$1"
+ local advisory_dir="${skill_dir}/advisories"
+
+ if [ ! -d "$advisory_dir" ] || [ ! -f "$advisory_dir/feed.json" ]; then
+ return 0
+ fi
+
+ echo " [Dry-run] Signing advisory artifacts with test key"
+
+ local key_file="$TEST_SIGNING_KEY_DIR/private.pem"
+ local pub_file="$TEST_SIGNING_KEY_DIR/public.pem"
+ local tmp_sig_bin
+
+ # Sign feed.json with Ed25519 (requires -rawin flag)
+ openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/feed.json" | \
+ openssl base64 -A > "$advisory_dir/feed.json.sig"
+
+ # Verify Ed25519 feed.json signature (requires -rawin flag)
+ tmp_sig_bin=$(mktemp)
+ openssl base64 -d -A -in "$advisory_dir/feed.json.sig" -out "$tmp_sig_bin"
+ if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/feed.json" >/dev/null 2>&1; then
+ echo "::error file=${skill_dir}/advisories/feed.json.sig::Feed signature verification failed after signing"
+ rm -f "$tmp_sig_bin"
+ return 1
+ fi
+ rm -f "$tmp_sig_bin"
+ echo " [Dry-run] Verified feed.json signature"
+
+ # Generate checksums.json
+ local feed_sha=$(sha256sum "$advisory_dir/feed.json" | awk '{print $1}')
+ local feed_size=$(stat -c%s "$advisory_dir/feed.json" 2>/dev/null || stat -f%z "$advisory_dir/feed.json")
+ local feed_sig_sha=$(sha256sum "$advisory_dir/feed.json.sig" | awk '{print $1}')
+ local feed_sig_size=$(stat -c%s "$advisory_dir/feed.json.sig" 2>/dev/null || stat -f%z "$advisory_dir/feed.json.sig")
+
+ jq -n \
+ --arg schema_version "1" \
+ --arg algorithm "sha256" \
+ --arg version "test" \
+ --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ --arg feed_sha "$feed_sha" \
+ --argjson feed_size "$feed_size" \
+ --arg feed_sig_sha "$feed_sig_sha" \
+ --argjson feed_sig_size "$feed_sig_size" \
+ '{
+ schema_version: $schema_version,
+ algorithm: $algorithm,
+ version: $version,
+ generated_at: $generated,
+ files: {
+ "advisories/feed.json": {sha256: $feed_sha, size: $feed_size},
+ "advisories/feed.json.sig": {sha256: $feed_sig_sha, size: $feed_sig_size}
+ }
+ }' > "$advisory_dir/checksums.json"
+
+ # Sign checksums.json with Ed25519 (requires -rawin flag)
+ openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/checksums.json" | \
+ openssl base64 -A > "$advisory_dir/checksums.json.sig"
+
+ # Verify Ed25519 checksums.json signature (requires -rawin flag)
+ tmp_sig_bin=$(mktemp)
+ openssl base64 -d -A -in "$advisory_dir/checksums.json.sig" -out "$tmp_sig_bin"
+ if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/checksums.json" >/dev/null 2>&1; then
+ echo "::error file=${skill_dir}/advisories/checksums.json.sig::Checksums signature verification failed after signing"
+ rm -f "$tmp_sig_bin"
+ return 1
+ fi
+ rm -f "$tmp_sig_bin"
+ echo " [Dry-run] Verified checksums.json signature"
+
+ # Copy public key
+ cp "$pub_file" "$advisory_dir/feed-signing-public.pem"
+
+ echo " [Dry-run] Advisory artifacts signed and verified with test key"
+ }
+
get_md_version() {
local md_file="$1"
awk '
@@ -263,6 +351,13 @@ jobs:
out_assets="${out_root}/release-assets"
mkdir -p "${out_assets}"
+ # --- Sign advisory artifacts if present (dry-run with test key) ---
+ if ! sign_advisory_artifacts "${skill_dir}"; then
+ failures=$((failures + 1))
+ echo "::endgroup::"
+ continue
+ fi
+
# --- Stage SBOM files preserving directory structure ---
staging_dir="$(mktemp -d)"
inner_dir="${staging_dir}/${skill_name}"
@@ -284,10 +379,28 @@ jobs:
cp "${json_path}" "${inner_dir}/skill.json"
+ # --- Remove test-only artifacts from staging (don't include in release zip) ---
+ # The test signatures/keys were needed for SBOM validation but shouldn't ship
+ if [ -d "${inner_dir}/advisories" ]; then
+ rm -f "${inner_dir}/advisories/feed.json.sig"
+ rm -f "${inner_dir}/advisories/checksums.json"
+ rm -f "${inner_dir}/advisories/checksums.json.sig"
+ rm -f "${inner_dir}/advisories/feed-signing-public.pem"
+ echo " [Dry-run] Removed test signatures from release staging"
+ fi
+
# --- Create zip preserving directory structure ---
zip_name="${skill_name}-v${version}.zip"
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .)
+ # --- Clean up test artifacts from source directory ---
+ if [ -d "${skill_dir}/advisories" ]; then
+ rm -f "${skill_dir}/advisories/feed.json.sig"
+ rm -f "${skill_dir}/advisories/checksums.json"
+ rm -f "${skill_dir}/advisories/checksums.json.sig"
+ rm -f "${skill_dir}/advisories/feed-signing-public.pem"
+ fi
+
# --- Generate checksums.json via jq ---
files_json="{}"
while IFS= read -r file; do
@@ -402,7 +515,7 @@ jobs:
echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}"
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate skill exists
run: |
@@ -472,10 +585,79 @@ jobs:
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 20
+ - name: Sign embedded advisory feed and verify
+ if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
+ 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: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json
+ signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json.sig
+ public_key_output: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed-signing-public.pem
+
+ - name: Generate embedded advisory checksums manifest
+ if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
+ run: |
+ set -euo pipefail
+ ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories"
+ FEED_SHA=$(sha256sum "$ADVISORY_DIR/feed.json" | awk '{print $1}')
+ FEED_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json")
+ FEED_SIG_SHA=$(sha256sum "$ADVISORY_DIR/feed.json.sig" | awk '{print $1}')
+ FEED_SIG_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json.sig" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json.sig")
+
+ jq -n \
+ --arg schema_version "1" \
+ --arg algorithm "sha256" \
+ --arg version "${{ steps.parse.outputs.version }}" \
+ --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ --arg repo "${{ github.repository }}" \
+ --arg feed_sha "$FEED_SHA" \
+ --argjson feed_size "$FEED_SIZE" \
+ --arg feed_sig_sha "$FEED_SIG_SHA" \
+ --argjson feed_sig_size "$FEED_SIG_SIZE" \
+ '{
+ schema_version: $schema_version,
+ algorithm: $algorithm,
+ version: $version,
+ generated_at: $generated,
+ repository: $repo,
+ files: {
+ "advisories/feed.json": {
+ sha256: $feed_sha,
+ size: $feed_size,
+ path: "advisories/feed.json"
+ },
+ "advisories/feed.json.sig": {
+ sha256: $feed_sig_sha,
+ size: $feed_sig_size,
+ path: "advisories/feed.json.sig"
+ }
+ }
+ }' > "$ADVISORY_DIR/checksums.json"
+
+ echo "Generated $ADVISORY_DIR/checksums.json"
+ jq . "$ADVISORY_DIR/checksums.json"
+
+ - name: Sign embedded advisory checksums and verify
+ if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
+ 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: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json
+ signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json.sig
+
+ - name: Show embedded advisory signing outputs
+ if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
+ run: |
+ ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories"
+ echo "Successfully signed embedded advisory artifacts:"
+ ls -la "$ADVISORY_DIR"
+
- name: Install clawhub CLI
if: steps.publishable.outputs.publishable == 'true'
run: npm install -g clawhub
@@ -597,6 +779,20 @@ jobs:
echo "=== Release assets ==="
ls -la release-assets/
+ - 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: release-assets/checksums.json
+ signature_file: release-assets/checksums.sig
+ public_key_output: release-assets/signing-public.pem
+
+ - name: Show signed release assets
+ run: |
+ echo "Signed and verified release-assets/checksums.json"
+ ls -la release-assets/
+
- name: Publish to ClawHub
if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
run: |
@@ -620,7 +816,7 @@ jobs:
--no-input
- name: Create GitHub Release
- uses: softprops/action-gh-release@v1
+ uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
tag_name: ${{ github.ref_name }}
@@ -637,22 +833,33 @@ jobs:
**Manual download with verification:**
```bash
- # 1. Download the release archive and checksums
+ # 1. Download the release archive, checksums, and signing material
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
+ curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
+ curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
- # 2. Verify archive checksum
+ # 2. Verify the checksums manifest signature (Ed25519)
+ openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
+ openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
+
+ # 3. Verify archive checksum from the signed manifest
echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c
- # 3. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
+ # 4. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
```
### Verification
- All files include SHA256 checksums in `checksums.json`:
+ `checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key.
+ Verify the signature first, then trust hashes from `checksums.json`:
```bash
- curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json | jq .
+ curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
+ curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
+ curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
+ openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
+ openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
```
### Files
diff --git a/MIGRATION-SIGNED-FEED.md b/MIGRATION-SIGNED-FEED.md
new file mode 100644
index 0000000..feeb108
--- /dev/null
+++ b/MIGRATION-SIGNED-FEED.md
@@ -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
diff --git a/README.md b/README.md
index 4c9199d..6399958 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,6 @@
-
We are featured on Product Hunt - upvote us and help us spread the word.
-
-

-
## Secure Your OpenClaw Bots with a Complete Security Skill Suite
Brought to you by Prompt Security, the Platform for AI Security
@@ -29,7 +25,7 @@
[](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)
+
@@ -202,6 +198,12 @@ Each skill release includes:
- `SKILL.md` - Main skill documentation
- Additional files from SBOM (scripts, configs, etc.)
+### Signing Operations Documentation
+
+For feed/release signing rollout and operations guidance:
+- [`SECURITY-SIGNING.md`](SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
+- [`MIGRATION-SIGNED-FEED.md`](MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan
+
---
## 🛠️ Offline Tools
diff --git a/SECURITY-SIGNING.md b/SECURITY-SIGNING.md
new file mode 100644
index 0000000..14321ee
--- /dev/null
+++ b/SECURITY-SIGNING.md
@@ -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://raw.githubusercontent.com/prompt-security/clawsec/main/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)
+- `/checksums.json`
+- `/checksums.json.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.json.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
diff --git a/pages/Home.tsx b/pages/Home.tsx
index 4bdc998..cae9913 100644
--- a/pages/Home.tsx
+++ b/pages/Home.tsx
@@ -220,24 +220,6 @@ export const Home: React.FC = () => {
-
-
- We are featured on Product Hunt - upvote us and help us spread the word.
-
-
-
-
-
diff --git a/skills/clawsec-suite/CHANGELOG.md b/skills/clawsec-suite/CHANGELOG.md
new file mode 100644
index 0000000..4458db9
--- /dev/null
+++ b/skills/clawsec-suite/CHANGELOG.md
@@ -0,0 +1,76 @@
+# 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.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
+```
diff --git a/skills/clawsec-suite/SKILL.md b/skills/clawsec-suite/SKILL.md
index eb0480f..3617d1d 100644
--- a/skills/clawsec-suite/SKILL.md
+++ b/skills/clawsec-suite/SKILL.md
@@ -1,12 +1,12 @@
---
name: clawsec-suite
-version: 0.0.9
-description: ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.
+version: 0.0.10
+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, jq, shasum]
+ bins: [curl, jq, shasum, openssl]
---
# ClawSec Suite
@@ -41,7 +41,7 @@ This means `clawsec-suite` can:
npx clawhub@latest install clawsec-suite
```
-### Option B: Manual download with verification
+### Option B: Manual download with signature + checksum verification
```bash
set -euo pipefail
@@ -56,14 +56,45 @@ DOWNLOAD_DIR="$TEMP_DIR/downloads"
trap 'rm -rf "$TEMP_DIR"' EXIT
mkdir -p "$DOWNLOAD_DIR"
-# 1) Download checksums manifest
+# Pinned release-signing public key (verify fingerprint out-of-band on first use)
+# Fingerprint (SHA-256 of SPKI DER): 35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854
+RELEASE_PUBKEY_SHA256="35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854"
+cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM'
+-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAtaRGONGp0Syl9EBS17hEYgGTwUtfZgigklS6vAe5MlQ=
+-----END PUBLIC KEY-----
+PEM
+
+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
+
+# 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"
+
+# 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"
+if ! openssl pkeyutl -verify \
+ -pubin \
+ -inkey "$TEMP_DIR/release-signing-public.pem" \
+ -sigfile "$TEMP_DIR/checksums.json.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
exit 1
fi
-# 2) Download every file listed in checksums and verify immediately
+echo "Checksums manifest signature verified."
+
+# 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")"
@@ -94,7 +125,7 @@ if [ "$DOWNLOAD_FAILED" -eq 1 ]; then
exit 1
fi
-# 3) Install files using paths from checksums.json
+# 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")"
@@ -109,7 +140,7 @@ 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\""
+echo "Next step (OpenClaw): node \"\$DEST/scripts/setup_advisory_hook.mjs\""
```
## OpenClaw Automation (Hook + Optional Cron)
@@ -147,6 +178,7 @@ node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --versio
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`:
@@ -163,10 +195,17 @@ 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 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:** 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.
+
### Quick feed check
```bash
@@ -245,7 +284,9 @@ npx clawhub@latest install clawtributor
## Security Notes
-- Always verify checksums before installing files manually.
+- 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.
diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/HOOK.md b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/HOOK.md
index 1681728..1849806 100644
--- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/HOOK.md
+++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/HOOK.md
@@ -24,7 +24,16 @@ and asks for user approval first.
## 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.
diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts
index ceaffb4..a7065b3 100644
--- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts
+++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { uniqueStrings } from "./lib/utils.mjs";
-import { isValidFeedPayload, loadRemoteFeed } from "./lib/feed.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";
@@ -10,6 +10,7 @@ import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } fro
const DEFAULT_FEED_URL =
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
+let unsignedModeWarningShown = false;
function expandHome(inputPath: string): string {
if (!inputPath) return inputPath;
@@ -49,16 +50,42 @@ function scannedRecently(lastScan: string | null, minIntervalSeconds: number): b
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
}
-async function loadFeed(feedUrl: string, localFeedPath: string): Promise {
- const remoteFeed = await loadRemoteFeed(feedUrl);
+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 {
+ 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;
- const fallbackRaw = await fs.readFile(localFeedPath, "utf8");
- const fallbackPayload = JSON.parse(fallbackRaw);
- if (!isValidFeedPayload(fallbackPayload)) {
- throw new Error(`Invalid advisory feed format in fallback file: ${localFeedPath}`);
- }
- return fallbackPayload;
+ 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 => {
@@ -69,15 +96,41 @@ const handler = async (event: HookEvent): Promise => {
);
const suiteDir = expandHome(process.env.CLAWSEC_SUITE_DIR || path.join(installRoot, "clawsec-suite"));
const localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json"));
+ const localFeedSignaturePath = expandHome(
+ process.env.CLAWSEC_LOCAL_FEED_SIG || `${localFeedPath}.sig`,
+ );
+ const localFeedChecksumsPath = expandHome(
+ process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || path.join(path.dirname(localFeedPath), "checksums.json"),
+ );
+ const localFeedChecksumsSignaturePath = expandHome(
+ process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || `${localFeedChecksumsPath}.sig`,
+ );
+ const feedPublicKeyPath = expandHome(
+ process.env.CLAWSEC_FEED_PUBLIC_KEY || path.join(suiteDir, "advisories", "feed-signing-public.pem"),
+ );
const stateFile = expandHome(
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
);
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)) {
@@ -86,7 +139,19 @@ const handler = async (event: HookEvent): Promise => {
let feed: FeedPayload;
try {
- feed = await loadFeed(feedUrl, localFeedPath);
+ 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;
diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs
index 6603ba2..4935da3 100644
--- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs
+++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs
@@ -1,5 +1,100 @@
+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}
+ * @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}
@@ -25,34 +120,392 @@ export function parseAffectedSpecifier(rawSpecifier) {
*/
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} feedUrl
- * @returns {Promise}
+ * @param {string} signatureRaw
+ * @returns {Buffer | null}
*/
-export async function loadRemoteFeed(feedUrl) {
- const fetchFn = /** @type {{ fetch?: Function }} */ (globalThis).fetch;
- if (typeof fetchFn !== "function") return 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 }}
+ */
+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} */ ({});
+ 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 {{ files: Record }} manifest
+ * @param {Record} expectedEntries
+ */
+function verifyChecksums(manifest, expectedEntries) {
+ for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
+ if (!entryName) continue;
+
+ const expectedDigest = manifest.files[entryName];
+ if (!expectedDigest) {
+ throw new Error(`Checksum manifest missing required entry: ${entryName}`);
+ }
+
+ const actualDigest = sha256Hex(entryContent);
+ if (actualDigest !== expectedDigest) {
+ throw new Error(`Checksum mismatch for ${entryName}`);
+ }
+ }
+}
+
+/**
+ * @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}
+ */
+async function fetchText(fetchFn, targetUrl) {
const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
+
try {
- const response = await fetchFn(feedUrl, {
+ const response = await fetchFn(targetUrl, {
method: "GET",
signal: controller.signal,
- headers: { accept: "application/json" },
+ headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
});
-
if (!response.ok) return null;
- const payload = await response.json();
- if (!isValidFeedPayload(payload)) return null;
- return payload;
- } catch {
+ 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}
+ */
+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} */ ({
+ [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}
+ */
+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;
+ }
+}
diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts
index 9ef2054..50ab0ed 100644
--- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts
+++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts
@@ -17,6 +17,7 @@ export type Advisory = {
};
export type FeedPayload = {
+ version: string;
updated?: string;
advisories: Advisory[];
};
diff --git a/skills/clawsec-suite/scripts/generate_checksums_json.mjs b/skills/clawsec-suite/scripts/generate_checksums_json.mjs
new file mode 100644
index 0000000..8b7a1c8
--- /dev/null
+++ b/skills/clawsec-suite/scripts/generate_checksums_json.mjs
@@ -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 ",
+ ` --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);
+});
diff --git a/skills/clawsec-suite/scripts/guarded_skill_install.mjs b/skills/clawsec-suite/scripts/guarded_skill_install.mjs
index 0b8b20e..6c01fb7 100644
--- a/skills/clawsec-suite/scripts/guarded_skill_install.mjs
+++ b/skills/clawsec-suite/scripts/guarded_skill_install.mjs
@@ -6,12 +6,21 @@ import os from "node:os";
import path from "node:path";
import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
-import { parseAffectedSpecifier, isValidFeedPayload, loadRemoteFeed } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
+import {
+ defaultChecksumsUrl,
+ parseAffectedSpecifier,
+ loadLocalFeed,
+ loadRemoteFeed,
+} from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
const DEFAULT_FEED_URL =
"https://raw.githubusercontent.com/prompt-security/clawsec/main/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 printUsage() {
@@ -87,12 +96,10 @@ function affectedSpecifierMatches(specifier, skillName, version) {
return versionMatches(version, parsed.versionSpec);
}
-function affectedSpecifierMatchesNameOnly(specifier, skillName) {
+function affectedSpecifierMatchesWithoutVersion(specifier, skillName) {
const parsed = parseAffectedSpecifier(specifier);
if (!parsed) return false;
- if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false;
- const vs = parsed.versionSpec.trim();
- return !vs || vs === "*" || vs.toLowerCase() === "any";
+ return normalizeSkillName(parsed.name) === normalizeSkillName(skillName);
}
function advisoryLooksHighRisk(advisory) {
@@ -108,17 +115,47 @@ function advisoryLooksHighRisk(advisory) {
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 = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED;
+ const localFeedSigPath = process.env.CLAWSEC_LOCAL_FEED_SIG || DEFAULT_LOCAL_FEED_SIG;
+ const localFeedChecksumsPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || DEFAULT_LOCAL_FEED_CHECKSUMS;
+ const localFeedChecksumsSigPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || DEFAULT_LOCAL_FEED_CHECKSUMS_SIG;
+ const feedPublicKeyPath = process.env.CLAWSEC_FEED_PUBLIC_KEY || DEFAULT_FEED_PUBLIC_KEY;
+ const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
+ const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
- const remoteFeed = await loadRemoteFeed(feedUrl);
+ 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 raw = await fs.readFile(localFeedPath, "utf8");
- const payload = JSON.parse(raw);
- if (!isValidFeedPayload(payload)) {
- throw new Error(`Invalid fallback advisory feed format: ${localFeedPath}`);
- }
- return { feed: payload, source: `local:${localFeedPath}` };
+ 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) {
@@ -133,7 +170,7 @@ function findMatches(feed, skillName, version) {
affected.filter((specifier) =>
version
? affectedSpecifierMatches(specifier, skillName, version)
- : affectedSpecifierMatchesNameOnly(specifier, skillName),
+ : affectedSpecifierMatchesWithoutVersion(specifier, skillName),
),
);
@@ -186,6 +223,12 @@ async function main() {
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);
diff --git a/skills/clawsec-suite/scripts/sign_detached_ed25519.mjs b/skills/clawsec-suite/scripts/sign_detached_ed25519.mjs
new file mode 100644
index 0000000..ccd3ac5
--- /dev/null
+++ b/skills/clawsec-suite/scripts/sign_detached_ed25519.mjs
@@ -0,0 +1,65 @@
+#!/usr/bin/env node
+
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+
+function usage() {
+ process.stderr.write(
+ [
+ "Usage:",
+ " node scripts/sign_detached_ed25519.mjs --key --in --out ",
+ "",
+ "Signs with Ed25519 private key and writes base64 detached signature to .",
+ "",
+ ].join("\n"),
+ );
+}
+
+function parseArgs(argv) {
+ const parsed = {};
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const token = argv[i];
+ if (token === "--key") {
+ parsed.keyPath = argv[++i];
+ } else if (token === "--in") {
+ parsed.inPath = argv[++i];
+ } else if (token === "--out") {
+ parsed.outPath = argv[++i];
+ } else if (token === "-h" || token === "--help") {
+ parsed.help = true;
+ } else {
+ throw new Error(`Unknown argument: ${token}`);
+ }
+ }
+
+ return parsed;
+}
+
+async function main() {
+ const { keyPath, inPath, outPath, help } = parseArgs(process.argv.slice(2));
+
+ if (help) {
+ usage();
+ process.exit(0);
+ }
+
+ if (!keyPath || !inPath || !outPath) {
+ usage();
+ throw new Error("Missing required arguments: --key, --in, --out");
+ }
+
+ const privateKeyPem = await fs.readFile(keyPath, "utf8");
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
+ const data = await fs.readFile(inPath);
+ const signature = crypto.sign(null, data, privateKey);
+ const signatureBase64 = signature.toString("base64");
+
+ await fs.writeFile(outPath, `${signatureBase64}\n`, "utf8");
+ process.stdout.write(`Signed ${inPath} -> ${outPath}\n`);
+}
+
+main().catch((error) => {
+ process.stderr.write(`${String(error)}\n`);
+ process.exit(1);
+});
diff --git a/skills/clawsec-suite/scripts/verify_detached_ed25519.mjs b/skills/clawsec-suite/scripts/verify_detached_ed25519.mjs
new file mode 100644
index 0000000..7c93823
--- /dev/null
+++ b/skills/clawsec-suite/scripts/verify_detached_ed25519.mjs
@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+
+function usage() {
+ process.stderr.write(
+ [
+ "Usage:",
+ " node scripts/verify_detached_ed25519.mjs --key --in --sig ",
+ "",
+ "Verifies Ed25519 detached signature against .",
+ "Exits 0 on success, 1 on verification failure.",
+ "",
+ ].join("\n"),
+ );
+}
+
+function parseArgs(argv) {
+ const parsed = {};
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const token = argv[i];
+ if (token === "--key") {
+ parsed.keyPath = argv[++i];
+ } else if (token === "--in") {
+ parsed.inPath = argv[++i];
+ } else if (token === "--sig") {
+ parsed.sigPath = argv[++i];
+ } else if (token === "-h" || token === "--help") {
+ parsed.help = true;
+ } else {
+ throw new Error(`Unknown argument: ${token}`);
+ }
+ }
+
+ return parsed;
+}
+
+async function main() {
+ const { keyPath, inPath, sigPath, help } = parseArgs(process.argv.slice(2));
+
+ if (help) {
+ usage();
+ process.exit(0);
+ }
+
+ if (!keyPath || !inPath || !sigPath) {
+ usage();
+ throw new Error("Missing required arguments: --key, --in, --sig");
+ }
+
+ const publicKeyPem = await fs.readFile(keyPath, "utf8");
+ const publicKey = crypto.createPublicKey(publicKeyPem);
+ const data = await fs.readFile(inPath);
+ const signatureRaw = await fs.readFile(sigPath, "utf8");
+ const signature = Buffer.from(signatureRaw.trim(), "base64");
+
+ const valid = crypto.verify(null, data, publicKey, signature);
+
+ if (valid) {
+ process.stdout.write(`Signature valid: ${inPath}\n`);
+ process.exit(0);
+ } else {
+ process.stderr.write(`Signature INVALID: ${inPath}\n`);
+ process.exit(1);
+ }
+}
+
+main().catch((error) => {
+ process.stderr.write(`${String(error)}\n`);
+ process.exit(1);
+});
diff --git a/skills/clawsec-suite/skill.json b/skills/clawsec-suite/skill.json
index 50813f4..5db2417 100644
--- a/skills/clawsec-suite/skill.json
+++ b/skills/clawsec-suite/skill.json
@@ -1,7 +1,7 @@
{
"name": "clawsec-suite",
- "version": "0.0.9",
- "description": "ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.",
+ "version": "0.0.10",
+ "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",
"homepage": "https://clawsec.prompt.security/",
@@ -19,7 +19,9 @@
"agents",
"ai",
"suite",
- "openclaw"
+ "openclaw",
+ "signature",
+ "verification"
],
"sbom": {
"files": [
@@ -28,6 +30,11 @@
"required": true,
"description": "Suite skill documentation and installation guide"
},
+ {
+ "path": "CHANGELOG.md",
+ "required": true,
+ "description": "Version history and security improvements changelog"
+ },
{
"path": "HEARTBEAT.md",
"required": true,
@@ -38,6 +45,26 @@
"required": true,
"description": "Embedded advisory feed seed (merged from clawsec-feed)"
},
+ {
+ "path": "advisories/feed.json.sig",
+ "required": true,
+ "description": "Detached Ed25519 signature for advisory feed"
+ },
+ {
+ "path": "advisories/checksums.json",
+ "required": true,
+ "description": "SHA-256 checksum manifest for advisory artifacts"
+ },
+ {
+ "path": "advisories/checksums.json.sig",
+ "required": true,
+ "description": "Detached Ed25519 signature for checksum manifest"
+ },
+ {
+ "path": "advisories/feed-signing-public.pem",
+ "required": true,
+ "description": "Pinned Ed25519 public key for feed signature verification"
+ },
{
"path": "hooks/clawsec-advisory-guardian/HOOK.md",
"required": true,
@@ -46,7 +73,7 @@
{
"path": "hooks/clawsec-advisory-guardian/handler.ts",
"required": true,
- "description": "OpenClaw hook handler for approval-gated advisory actions"
+ "description": "OpenClaw hook handler for approval-gated advisory actions with signature verification"
},
{
"path": "hooks/clawsec-advisory-guardian/lib/utils.mjs",
@@ -61,7 +88,22 @@
{
"path": "hooks/clawsec-advisory-guardian/lib/feed.mjs",
"required": true,
- "description": "Shared advisory feed loading and validation"
+ "description": "Advisory feed loading with Ed25519 signature and checksum manifest verification"
+ },
+ {
+ "path": "hooks/clawsec-advisory-guardian/lib/types.ts",
+ "required": true,
+ "description": "TypeScript type definitions for hook and feed structures"
+ },
+ {
+ "path": "hooks/clawsec-advisory-guardian/lib/state.ts",
+ "required": true,
+ "description": "Advisory state persistence and loading"
+ },
+ {
+ "path": "hooks/clawsec-advisory-guardian/lib/matching.ts",
+ "required": true,
+ "description": "Advisory-to-skill matching and alert message generation"
},
{
"path": "scripts/setup_advisory_hook.mjs",
@@ -76,7 +118,22 @@
{
"path": "scripts/guarded_skill_install.mjs",
"required": true,
- "description": "Two-step confirmation installer that blocks risky skill installs until explicit second approval"
+ "description": "Two-step confirmation installer with signature verification that blocks risky skill installs"
+ },
+ {
+ "path": "scripts/sign_detached_ed25519.mjs",
+ "required": false,
+ "description": "Utility script for generating Ed25519 detached signatures"
+ },
+ {
+ "path": "scripts/verify_detached_ed25519.mjs",
+ "required": false,
+ "description": "Utility script for verifying Ed25519 detached signatures"
+ },
+ {
+ "path": "scripts/generate_checksums_json.mjs",
+ "required": false,
+ "description": "Utility script for generating SHA-256 checksum manifests"
}
]
},
@@ -85,14 +142,20 @@
"source_skill": "clawsec-feed",
"source_version": "0.0.4",
"paths": [
- "advisories/feed.json"
+ "advisories/feed.json",
+ "advisories/feed.json.sig",
+ "advisories/checksums.json",
+ "advisories/checksums.json.sig",
+ "advisories/feed-signing-public.pem"
],
"capabilities": [
"advisory-feed monitoring",
"new-advisory detection",
"affected-skill cross-reference",
"approval-gated malicious-skill removal recommendations",
- "double-confirmation gating for risky skill installs"
+ "double-confirmation gating for risky skill installs",
+ "Ed25519 signature verification",
+ "checksum manifest verification"
],
"standalone_available": true,
"deprecation_plan": "standalone skill may be retired after suite migration is verified"
@@ -153,7 +216,8 @@
"bins": [
"curl",
"jq",
- "shasum"
+ "shasum",
+ "openssl"
]
},
"triggers": [
diff --git a/skills/clawsec-suite/test/feed_verification.test.mjs b/skills/clawsec-suite/test/feed_verification.test.mjs
new file mode 100644
index 0000000..c195b5f
--- /dev/null
+++ b/skills/clawsec-suite/test/feed_verification.test.mjs
@@ -0,0 +1,568 @@
+#!/usr/bin/env node
+
+/**
+ * Feed verification tests for clawsec-suite.
+ *
+ * Tests cover:
+ * - Signature verification success/failure/tampered cases
+ * - Checksum manifest verification success/failure/tampered cases
+ * - Fail-closed behavior when signatures are missing/invalid
+ * - Temporary compatibility flag behavior
+ *
+ * Run: node skills/clawsec-suite/test/feed_verification.test.mjs
+ */
+
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
+
+// Dynamic import to ensure we test the actual module
+const { verifySignedPayload, loadLocalFeed, isValidFeedPayload } = await import(
+ `${LIB_PATH}/feed.mjs`
+);
+
+let tempDir;
+let passCount = 0;
+let failCount = 0;
+
+function pass(name) {
+ passCount++;
+ console.log(`✓ ${name}`);
+}
+
+function fail(name, error) {
+ failCount++;
+ console.error(`✗ ${name}`);
+ console.error(` ${String(error)}`);
+}
+
+function generateEd25519KeyPair() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
+ return { publicKeyPem, privateKeyPem };
+}
+
+function signPayload(data, privateKeyPem) {
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
+ const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
+ return signature.toString("base64");
+}
+
+function createValidFeed() {
+ return JSON.stringify(
+ {
+ version: "1.0.0",
+ updated: "2026-02-08T12:00:00Z",
+ advisories: [
+ {
+ id: "TEST-001",
+ severity: "high",
+ affected: ["test-skill@1.0.0"],
+ },
+ ],
+ },
+ null,
+ 2,
+ );
+}
+
+function createChecksumManifest(files) {
+ const checksums = {};
+ for (const [name, content] of Object.entries(files)) {
+ checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
+ }
+ return JSON.stringify(
+ {
+ schema_version: "1.0",
+ algorithm: "sha256",
+ files: checksums,
+ },
+ null,
+ 2,
+ );
+}
+
+async function setupTestDir() {
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-test-"));
+}
+
+async function cleanupTestDir() {
+ if (tempDir) {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: verifySignedPayload - valid signature
+// -----------------------------------------------------------------------------
+async function testVerifySignedPayload_ValidSignature() {
+ const testName = "verifySignedPayload: valid signature passes";
+ try {
+ const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
+ const payload = "test payload content";
+ const signature = signPayload(payload, privateKeyPem);
+
+ const result = verifySignedPayload(payload, signature, publicKeyPem);
+
+ if (result === true) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected true, got false");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: verifySignedPayload - invalid signature
+// -----------------------------------------------------------------------------
+async function testVerifySignedPayload_InvalidSignature() {
+ const testName = "verifySignedPayload: invalid signature fails";
+ try {
+ const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
+ const payload = "test payload content";
+ const signature = signPayload(payload, privateKeyPem);
+
+ // Tamper with payload
+ const tamperedPayload = "TAMPERED payload content";
+ const result = verifySignedPayload(tamperedPayload, signature, publicKeyPem);
+
+ if (result === false) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected false for tampered payload, got true");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: verifySignedPayload - wrong key
+// -----------------------------------------------------------------------------
+async function testVerifySignedPayload_WrongKey() {
+ const testName = "verifySignedPayload: wrong key fails";
+ try {
+ const keyPair1 = generateEd25519KeyPair();
+ const keyPair2 = generateEd25519KeyPair();
+ const payload = "test payload content";
+ const signature = signPayload(payload, keyPair1.privateKeyPem);
+
+ // Verify with different public key
+ const result = verifySignedPayload(payload, signature, keyPair2.publicKeyPem);
+
+ if (result === false) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected false for wrong key, got true");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: verifySignedPayload - malformed signature
+// -----------------------------------------------------------------------------
+async function testVerifySignedPayload_MalformedSignature() {
+ const testName = "verifySignedPayload: malformed signature fails";
+ try {
+ const { publicKeyPem } = generateEd25519KeyPair();
+ const payload = "test payload content";
+
+ const result = verifySignedPayload(payload, "not-valid-base64!!!", publicKeyPem);
+
+ if (result === false) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected false for malformed signature, got true");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: verifySignedPayload - empty signature
+// -----------------------------------------------------------------------------
+async function testVerifySignedPayload_EmptySignature() {
+ const testName = "verifySignedPayload: empty signature fails";
+ try {
+ const { publicKeyPem } = generateEd25519KeyPair();
+ const payload = "test payload content";
+
+ const result = verifySignedPayload(payload, "", publicKeyPem);
+
+ if (result === false) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected false for empty signature, got true");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: verifySignedPayload - JSON-wrapped signature format
+// -----------------------------------------------------------------------------
+async function testVerifySignedPayload_JsonWrappedSignature() {
+ const testName = "verifySignedPayload: JSON-wrapped signature passes";
+ try {
+ const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
+ const payload = "test payload content";
+ const signatureBase64 = signPayload(payload, privateKeyPem);
+ const jsonWrapped = JSON.stringify({ signature: signatureBase64 });
+
+ const result = verifySignedPayload(payload, jsonWrapped, publicKeyPem);
+
+ if (result === true) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected true for JSON-wrapped signature, got false");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: loadLocalFeed - valid signed feed
+// -----------------------------------------------------------------------------
+async function testLoadLocalFeed_ValidSignedFeed() {
+ const testName = "loadLocalFeed: valid signed feed loads successfully";
+ try {
+ const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
+ const feedContent = createValidFeed();
+ const feedSignature = signPayload(feedContent, privateKeyPem);
+
+ // Create checksum manifest
+ const checksumManifest = createChecksumManifest({
+ "feed.json": feedContent,
+ "feed.json.sig": feedSignature + "\n",
+ "feed-signing-public.pem": publicKeyPem,
+ });
+ const checksumSignature = signPayload(checksumManifest, privateKeyPem);
+
+ // Write files
+ const feedPath = path.join(tempDir, "feed.json");
+ const sigPath = path.join(tempDir, "feed.json.sig");
+ const checksumPath = path.join(tempDir, "checksums.json");
+ const checksumSigPath = path.join(tempDir, "checksums.json.sig");
+ const keyPath = path.join(tempDir, "feed-signing-public.pem");
+
+ 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: "feed-signing-public.pem",
+ });
+
+ if (feed && feed.version === "1.0.0" && feed.advisories.length === 1) {
+ pass(testName);
+ } else {
+ fail(testName, "Feed did not load with expected content");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: loadLocalFeed - tampered feed fails (fail-closed)
+// -----------------------------------------------------------------------------
+async function testLoadLocalFeed_TamperedFeedFails() {
+ const testName = "loadLocalFeed: tampered feed fails (fail-closed)";
+ try {
+ const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
+ const feedContent = createValidFeed();
+ const feedSignature = signPayload(feedContent, privateKeyPem);
+
+ // Tamper with feed after signing
+ const tamperedFeed = feedContent.replace("TEST-001", "TAMPERED-001");
+
+ const feedPath = path.join(tempDir, "tampered-feed.json");
+ const sigPath = path.join(tempDir, "tampered-feed.json.sig");
+
+ await fs.writeFile(feedPath, tamperedFeed);
+ await fs.writeFile(sigPath, feedSignature + "\n");
+
+ let didFail = false;
+ try {
+ await loadLocalFeed(feedPath, {
+ signaturePath: sigPath,
+ publicKeyPem,
+ verifyChecksumManifest: false,
+ });
+ } catch {
+ didFail = true;
+ }
+
+ if (didFail) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected failure for tampered feed, but it loaded");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: loadLocalFeed - missing signature fails (fail-closed)
+// -----------------------------------------------------------------------------
+async function testLoadLocalFeed_MissingSignatureFails() {
+ const testName = "loadLocalFeed: missing signature fails (fail-closed)";
+ try {
+ const { publicKeyPem } = generateEd25519KeyPair();
+ const feedContent = createValidFeed();
+
+ const feedPath = path.join(tempDir, "nosig-feed.json");
+ const sigPath = path.join(tempDir, "nosig-feed.json.sig");
+
+ await fs.writeFile(feedPath, feedContent);
+ // Don't write signature file
+
+ let didFail = false;
+ try {
+ await loadLocalFeed(feedPath, {
+ signaturePath: sigPath,
+ publicKeyPem,
+ verifyChecksumManifest: false,
+ });
+ } catch {
+ didFail = true;
+ }
+
+ if (didFail) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected failure for missing signature, but it loaded");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: loadLocalFeed - allowUnsigned bypasses verification
+// -----------------------------------------------------------------------------
+async function testLoadLocalFeed_AllowUnsignedBypasses() {
+ const testName = "loadLocalFeed: allowUnsigned=true bypasses verification";
+ try {
+ const feedContent = createValidFeed();
+
+ const feedPath = path.join(tempDir, "unsigned-feed.json");
+ await fs.writeFile(feedPath, feedContent);
+
+ const feed = await loadLocalFeed(feedPath, {
+ allowUnsigned: true,
+ verifyChecksumManifest: false,
+ });
+
+ if (feed && feed.version === "1.0.0") {
+ pass(testName);
+ } else {
+ fail(testName, "Feed did not load with allowUnsigned=true");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: loadLocalFeed - checksum mismatch fails
+// -----------------------------------------------------------------------------
+async function testLoadLocalFeed_ChecksumMismatchFails() {
+ const testName = "loadLocalFeed: checksum mismatch fails";
+ try {
+ const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
+ const feedContent = createValidFeed();
+ const feedSignature = signPayload(feedContent, privateKeyPem);
+
+ // Create checksum manifest with WRONG hash
+ const badChecksumManifest = JSON.stringify(
+ {
+ schema_version: "1.0",
+ algorithm: "sha256",
+ files: {
+ "feed.json": "0".repeat(64), // Wrong hash
+ "feed.json.sig":
+ crypto.createHash("sha256").update(feedSignature + "\n").digest("hex"),
+ },
+ },
+ null,
+ 2,
+ );
+ const checksumSignature = signPayload(badChecksumManifest, privateKeyPem);
+
+ const feedPath = path.join(tempDir, "badcs-feed.json");
+ const sigPath = path.join(tempDir, "badcs-feed.json.sig");
+ const checksumPath = path.join(tempDir, "badcs-checksums.json");
+ const checksumSigPath = path.join(tempDir, "badcs-checksums.json.sig");
+
+ await fs.writeFile(feedPath, feedContent);
+ await fs.writeFile(sigPath, feedSignature + "\n");
+ await fs.writeFile(checksumPath, badChecksumManifest);
+ await fs.writeFile(checksumSigPath, checksumSignature + "\n");
+
+ let didFail = false;
+ try {
+ await loadLocalFeed(feedPath, {
+ signaturePath: sigPath,
+ checksumsPath: checksumPath,
+ checksumsSignaturePath: checksumSigPath,
+ publicKeyPem,
+ verifyChecksumManifest: true,
+ });
+ } catch {
+ didFail = true;
+ }
+
+ if (didFail) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected failure for checksum mismatch, but it loaded");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: isValidFeedPayload - valid feed
+// -----------------------------------------------------------------------------
+async function testIsValidFeedPayload_Valid() {
+ const testName = "isValidFeedPayload: valid feed passes";
+ try {
+ const feed = {
+ version: "1.0.0",
+ advisories: [
+ {
+ id: "TEST-001",
+ severity: "high",
+ affected: ["test-skill@1.0.0"],
+ },
+ ],
+ };
+
+ if (isValidFeedPayload(feed)) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected valid feed to pass validation");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: isValidFeedPayload - missing version fails
+// -----------------------------------------------------------------------------
+async function testIsValidFeedPayload_MissingVersion() {
+ const testName = "isValidFeedPayload: missing version fails";
+ try {
+ const feed = {
+ advisories: [
+ {
+ id: "TEST-001",
+ severity: "high",
+ affected: [],
+ },
+ ],
+ };
+
+ if (!isValidFeedPayload(feed)) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected feed without version to fail validation");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: isValidFeedPayload - advisory missing id fails
+// -----------------------------------------------------------------------------
+async function testIsValidFeedPayload_AdvisoryMissingId() {
+ const testName = "isValidFeedPayload: advisory missing id fails";
+ try {
+ const feed = {
+ version: "1.0.0",
+ advisories: [
+ {
+ severity: "high",
+ affected: [],
+ },
+ ],
+ };
+
+ if (!isValidFeedPayload(feed)) {
+ pass(testName);
+ } else {
+ fail(testName, "Expected advisory without id to fail validation");
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Main test runner
+// -----------------------------------------------------------------------------
+async function runTests() {
+ console.log("=== ClawSec Feed Verification Tests ===\n");
+
+ await setupTestDir();
+
+ try {
+ // Signature verification tests
+ await testVerifySignedPayload_ValidSignature();
+ await testVerifySignedPayload_InvalidSignature();
+ await testVerifySignedPayload_WrongKey();
+ await testVerifySignedPayload_MalformedSignature();
+ await testVerifySignedPayload_EmptySignature();
+ await testVerifySignedPayload_JsonWrappedSignature();
+
+ // Local feed loading tests
+ await testLoadLocalFeed_ValidSignedFeed();
+ await testLoadLocalFeed_TamperedFeedFails();
+ await testLoadLocalFeed_MissingSignatureFails();
+ await testLoadLocalFeed_AllowUnsignedBypasses();
+ await testLoadLocalFeed_ChecksumMismatchFails();
+
+ // Feed payload validation tests
+ await testIsValidFeedPayload_Valid();
+ await testIsValidFeedPayload_MissingVersion();
+ await testIsValidFeedPayload_AdvisoryMissingId();
+ } finally {
+ await cleanupTestDir();
+ }
+
+ 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);
+});
diff --git a/skills/clawsec-suite/test/guarded_install.test.mjs b/skills/clawsec-suite/test/guarded_install.test.mjs
new file mode 100644
index 0000000..8e539f9
--- /dev/null
+++ b/skills/clawsec-suite/test/guarded_install.test.mjs
@@ -0,0 +1,378 @@
+#!/usr/bin/env node
+
+/**
+ * Guarded skill install tests for clawsec-suite.
+ *
+ * Tests cover:
+ * - Conservative matching when version is omitted
+ * - Precise version matching when version is provided
+ * - Exit code 42 for advisory match requiring confirmation
+ * - High-risk advisory detection
+ *
+ * Run: node skills/clawsec-suite/test/guarded_install.test.mjs
+ */
+
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "guarded_skill_install.mjs");
+
+let tempDir;
+let passCount = 0;
+let failCount = 0;
+
+function pass(name) {
+ passCount++;
+ console.log(`✓ ${name}`);
+}
+
+function fail(name, error) {
+ failCount++;
+ console.error(`✗ ${name}`);
+ console.error(` ${String(error)}`);
+}
+
+function generateEd25519KeyPair() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
+ return { publicKeyPem, privateKeyPem };
+}
+
+function signPayload(data, privateKeyPem) {
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
+ const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
+ return signature.toString("base64");
+}
+
+function createFeed(advisories) {
+ return JSON.stringify(
+ {
+ version: "1.0.0",
+ updated: "2026-02-08T12:00:00Z",
+ advisories,
+ },
+ null,
+ 2,
+ );
+}
+
+function createChecksumManifest(files) {
+ const checksums = {};
+ for (const [name, content] of Object.entries(files)) {
+ checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
+ }
+ return JSON.stringify(
+ {
+ schema_version: "1.0",
+ algorithm: "sha256",
+ files: checksums,
+ },
+ null,
+ 2,
+ );
+}
+
+async function setupTestDir() {
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-install-test-"));
+}
+
+async function cleanupTestDir() {
+ if (tempDir) {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+}
+
+async function setupSignedFeed(advisories, keyPair) {
+ const feedContent = createFeed(advisories);
+ const feedSignature = signPayload(feedContent, keyPair.privateKeyPem);
+
+ const checksumManifest = createChecksumManifest({
+ "feed.json": feedContent,
+ "feed.json.sig": feedSignature + "\n",
+ "feed-signing-public.pem": keyPair.publicKeyPem,
+ });
+ const checksumSignature = signPayload(checksumManifest, keyPair.privateKeyPem);
+
+ const advisoriesDir = path.join(tempDir, "advisories");
+ await fs.mkdir(advisoriesDir, { recursive: true });
+
+ await fs.writeFile(path.join(advisoriesDir, "feed.json"), feedContent);
+ await fs.writeFile(path.join(advisoriesDir, "feed.json.sig"), feedSignature + "\n");
+ await fs.writeFile(path.join(advisoriesDir, "checksums.json"), checksumManifest);
+ await fs.writeFile(path.join(advisoriesDir, "checksums.json.sig"), checksumSignature + "\n");
+ await fs.writeFile(path.join(advisoriesDir, "feed-signing-public.pem"), keyPair.publicKeyPem);
+
+ return advisoriesDir;
+}
+
+function runGuardedInstall(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", (data) => {
+ stdout += data.toString();
+ });
+
+ proc.stderr.on("data", (data) => {
+ stderr += data.toString();
+ });
+
+ proc.on("close", (code) => {
+ resolve({ code, stdout, stderr });
+ });
+ });
+}
+
+// -----------------------------------------------------------------------------
+// Test: Conservative matching when version is omitted
+// -----------------------------------------------------------------------------
+async function testConservativeMatchingWithoutVersion() {
+ const testName = "guarded_install: conservative matching without version triggers advisory";
+ try {
+ const keyPair = generateEd25519KeyPair();
+ const advisoriesDir = await setupSignedFeed(
+ [
+ {
+ id: "TEST-001",
+ severity: "high",
+ affected: ["test-skill@1.0.0", "test-skill@1.0.1"],
+ },
+ ],
+ keyPair,
+ );
+
+ const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
+ CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
+ CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
+ CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
+ CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
+ CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
+ CLAWSEC_FEED_URL: "file:///nonexistent", // Force local fallback
+ });
+
+ if (result.code === 42 && result.stdout.includes("Conservative")) {
+ pass(testName);
+ } else {
+ fail(testName, `Expected exit 42 with conservative message, got ${result.code}: ${result.stdout}`);
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: Precise version matching
+// -----------------------------------------------------------------------------
+async function testPreciseVersionMatching() {
+ const testName = "guarded_install: precise version matching only matches exact version";
+ try {
+ const keyPair = generateEd25519KeyPair();
+ const advisoriesDir = await setupSignedFeed(
+ [
+ {
+ id: "TEST-001",
+ severity: "high",
+ affected: ["test-skill@1.0.0"],
+ },
+ ],
+ keyPair,
+ );
+
+ // Version 2.0.0 should NOT match advisory for 1.0.0
+ const result = await runGuardedInstall(
+ ["--skill", "test-skill", "--version", "2.0.0", "--dry-run"],
+ {
+ CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
+ CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
+ CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
+ CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
+ CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
+ CLAWSEC_FEED_URL: "file:///nonexistent",
+ },
+ );
+
+ if (result.code === 0 && !result.stdout.includes("Advisory matches")) {
+ pass(testName);
+ } else {
+ fail(testName, `Expected exit 0 without match, got ${result.code}: ${result.stdout}`);
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: Version match triggers confirmation requirement
+// -----------------------------------------------------------------------------
+async function testVersionMatchTriggersConfirmation() {
+ const testName = "guarded_install: matching version triggers exit 42";
+ try {
+ const keyPair = generateEd25519KeyPair();
+ const advisoriesDir = await setupSignedFeed(
+ [
+ {
+ id: "TEST-001",
+ severity: "high",
+ affected: ["test-skill@1.0.0"],
+ },
+ ],
+ keyPair,
+ );
+
+ const result = await runGuardedInstall(
+ ["--skill", "test-skill", "--version", "1.0.0", "--dry-run"],
+ {
+ CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
+ CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
+ CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
+ CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
+ CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
+ CLAWSEC_FEED_URL: "file:///nonexistent",
+ },
+ );
+
+ if (result.code === 42 && result.stdout.includes("Advisory matches")) {
+ pass(testName);
+ } else {
+ fail(testName, `Expected exit 42 with advisory match, got ${result.code}: ${result.stdout}`);
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: --confirm-advisory allows proceeding
+// -----------------------------------------------------------------------------
+async function testConfirmAdvisoryAllowsProceeding() {
+ const testName = "guarded_install: --confirm-advisory with --dry-run proceeds";
+ try {
+ const keyPair = generateEd25519KeyPair();
+ const advisoriesDir = await setupSignedFeed(
+ [
+ {
+ id: "TEST-001",
+ severity: "high",
+ affected: ["test-skill@1.0.0"],
+ },
+ ],
+ keyPair,
+ );
+
+ const result = await runGuardedInstall(
+ ["--skill", "test-skill", "--version", "1.0.0", "--confirm-advisory", "--dry-run"],
+ {
+ CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
+ CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
+ CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
+ CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
+ CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
+ CLAWSEC_FEED_URL: "file:///nonexistent",
+ },
+ );
+
+ if (result.code === 0 && result.stdout.includes("Dry run")) {
+ pass(testName);
+ } else {
+ fail(testName, `Expected exit 0 with dry run message, got ${result.code}: ${result.stdout}`);
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: allowUnsigned bypass warning
+// -----------------------------------------------------------------------------
+async function testAllowUnsignedWarning() {
+ const testName = "guarded_install: CLAWSEC_ALLOW_UNSIGNED_FEED shows warning";
+ try {
+ // Create unsigned feed (no signatures)
+ const feedContent = createFeed([]);
+ const feedPath = path.join(tempDir, "unsigned-feed.json");
+ await fs.writeFile(feedPath, feedContent);
+
+ const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
+ CLAWSEC_LOCAL_FEED: feedPath,
+ CLAWSEC_ALLOW_UNSIGNED_FEED: "1",
+ CLAWSEC_VERIFY_CHECKSUM_MANIFEST: "0",
+ CLAWSEC_FEED_URL: "file:///nonexistent",
+ });
+
+ if (result.stderr.includes("CLAWSEC_ALLOW_UNSIGNED_FEED")) {
+ pass(testName);
+ } else {
+ fail(testName, `Expected unsigned mode warning, got: ${result.stderr}`);
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Test: Missing signature fails without allowUnsigned
+// -----------------------------------------------------------------------------
+async function testMissingSignatureFails() {
+ const testName = "guarded_install: missing signature fails without allowUnsigned";
+ try {
+ const feedContent = createFeed([]);
+ const feedPath = path.join(tempDir, "nosig-feed.json");
+ await fs.writeFile(feedPath, feedContent);
+
+ const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
+ CLAWSEC_LOCAL_FEED: feedPath,
+ CLAWSEC_FEED_URL: "file:///nonexistent",
+ });
+
+ if (result.code === 1) {
+ pass(testName);
+ } else {
+ fail(testName, `Expected exit 1 for missing signature, got ${result.code}`);
+ }
+ } catch (error) {
+ fail(testName, error);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Main test runner
+// -----------------------------------------------------------------------------
+async function runTests() {
+ console.log("=== ClawSec Guarded Install Tests ===\n");
+
+ await setupTestDir();
+
+ try {
+ await testConservativeMatchingWithoutVersion();
+ await testPreciseVersionMatching();
+ await testVersionMatchTriggersConfirmation();
+ await testConfirmAdvisoryAllowsProceeding();
+ await testAllowUnsignedWarning();
+ await testMissingSignatureFails();
+ } finally {
+ await cleanupTestDir();
+ }
+
+ 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);
+});