Integration/signing work (#20)

* ci: sign advisory feed and checksums in workflows

* feat(clawsec-suite): add verifier-side signature and checksum enforcement

Implements cryptographic verification for advisory feed loading:

- Ed25519 detached signature verification for feed.json
- Supports raw base64 and JSON-wrapped signature formats
- Pinned public key at advisories/feed-signing-public.pem

- SHA-256 checksum manifest (checksums.json) verification
- Signed checksums.json.sig prevents partial artifact substitution
- Verifies feed.json, feed.json.sig, and public key against manifest

- Remote feed: returns null on verification failure (triggers fallback)
- Local feed: throws on verification failure (hard fail)
- No silent bypass of verification

- CLAWSEC_ALLOW_UNSIGNED_FEED=1 temporarily bypasses verification
- Warning logged when bypass mode is enabled
- Intended for transition period only

- guarded_skill_install without --version matches any advisory for skill
- Encourages explicit version specification

- scripts/sign_detached_ed25519.mjs - signing utility
- scripts/verify_detached_ed25519.mjs - verification utility
- scripts/generate_checksums_json.mjs - checksum manifest generator
- test/feed_verification.test.mjs - 14 verification tests
- test/guarded_install.test.mjs - 6 install flow tests

- hooks/.../lib/feed.mjs - full rewrite with verification
- hooks/.../handler.ts - verification options integration
- scripts/guarded_skill_install.mjs - verification integration
- skill.json - v0.0.9, new SBOM entries, openssl requirement
- SKILL.md - signed install flow, env vars documentation
- HOOK.md - new environment variables
- ci.yml - added verification test job

Refs: fail-closed verification, Ed25519 signatures, checksum manifests

* fix: update action versions in CI workflows for improved stability

* chore(clawsec-suite): bump version to 0.0.10

* feat: enhance security measures in asset deployment and add changelog for version history

* feat: add dry-run signing for advisory artifacts and generate checksums

* fix: enhance error handling in loadRemoteFeed for security policy violations

* feat: implement Ed25519 signing and verification for advisory artifacts and checksums

* feat: implement signing and verification for advisory artifacts and checksums in workflows

* feat: update dry-run signing key generation to use Ed25519 algorithm

* feat: update Ed25519 signing and verification to use -rawin flag for compatibility

* feat: add public key copying to advisory directory and implement safe basename extraction for URLs

* feat: remove Product Hunt promotion section from README and Home page
This commit is contained in:
davida-ps
2026-02-12 17:49:34 +01:00
committed by GitHub
parent 331219eec3
commit 5ee8587b1e
24 changed files with 2970 additions and 153 deletions
+110
View File
@@ -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}"
+2
View File
@@ -0,0 +1,2 @@
ruff==0.6.9
bandit==1.7.9
+27 -12
View File
@@ -11,8 +11,8 @@ jobs:
name: Lint TypeScript/React name: Lint TypeScript/React
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@v4 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
@@ -28,12 +28,14 @@ jobs:
name: Lint Python name: Lint Python
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@v5 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: '3.12' python-version: '3.12'
cache: 'pip'
cache-dependency-path: '.github/requirements-lint-python.txt'
- name: Install linters - name: Install linters
run: pip install ruff bandit run: python -m pip install -r .github/requirements-lint-python.txt
- name: Ruff (lint + format check) - name: Ruff (lint + format check)
run: ruff check utils/ --output-format=github run: ruff check utils/ --output-format=github
- name: Bandit (security) - name: Bandit (security)
@@ -43,9 +45,9 @@ jobs:
name: Lint Shell Scripts name: Lint Shell Scripts
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: ShellCheck - name: ShellCheck
uses: ludeeus/action-shellcheck@master uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
with: with:
scandir: './scripts' scandir: './scripts'
severity: warning severity: warning
@@ -54,9 +56,9 @@ jobs:
name: Security Scan name: Security Scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Trivy FS Scan - name: Trivy FS Scan
uses: aquasecurity/trivy-action@master uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with: with:
scan-type: 'fs' scan-type: 'fs'
scan-ref: '.' scan-ref: '.'
@@ -64,7 +66,7 @@ jobs:
exit-code: '1' exit-code: '1'
ignore-unfixed: true ignore-unfixed: true
- name: Trivy Config Scan - name: Trivy Config Scan
uses: aquasecurity/trivy-action@master uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with: with:
scan-type: 'config' scan-type: 'config'
scan-ref: '.' scan-ref: '.'
@@ -75,8 +77,8 @@ jobs:
name: Dependency Audit name: Dependency Audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@v4 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
@@ -85,3 +87,16 @@ jobs:
run: npm audit --audit-level=high --registry=https://registry.npmjs.org run: npm audit --audit-level=high --registry=https://registry.npmjs.org
- name: Check for outdated deps - name: Check for outdated deps
run: npm outdated || true 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
+53 -16
View File
@@ -7,6 +7,7 @@ on:
permissions: permissions:
contents: write contents: write
issues: write issues: write
pull-requests: write
concurrency: concurrency:
group: community-advisory group: community-advisory
@@ -14,7 +15,9 @@ concurrency:
env: env:
FEED_PATH: advisories/feed.json FEED_PATH: advisories/feed.json
FEED_SIG_PATH: advisories/feed.json.sig
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
jobs: jobs:
process-advisory: process-advisory:
@@ -22,7 +25,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 1 fetch-depth: 1
@@ -196,46 +199,80 @@ jobs:
exit 1 exit 1
fi fi
- name: Commit changes - name: Sign advisory feed and verify
if: steps.parse.outputs.already_exists != 'true' if: steps.parse.outputs.already_exists != 'true'
run: | uses: ./.github/actions/sign-and-verify
git config user.name "github-actions[bot]" with:
git config user.email "github-actions[bot]@users.noreply.github.com" 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 }}" - name: Create Pull Request
git commit -m "chore: add community advisory $ADVISORY_ID 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 - name: Comment on issue
if: steps.parse.outputs.already_exists != 'true' if: steps.parse.outputs.already_exists != 'true'
uses: actions/github-script@v7 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const advisoryId = '${{ steps.parse.outputs.advisory_id }}'; 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({ await github.rest.issues.createComment({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: context.issue.number, 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}\` **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!` Thank you for your contribution to community security!`
}); });
- name: Comment if already exists - name: Comment if already exists
if: steps.parse.outputs.already_exists == 'true' if: steps.parse.outputs.already_exists == 'true'
uses: actions/github-script@v7 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
script: | script: |
const advisoryId = '${{ steps.parse.outputs.advisory_id }}'; const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
+149 -34
View File
@@ -23,10 +23,11 @@ jobs:
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Auto-discover skills from releases - name: Auto-discover skills from releases
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
run: | run: |
@@ -48,17 +49,17 @@ jobs:
} }
export -f download_asset # Export for use in subshells (while loop) export -f download_asset # Export for use in subshells (while loop)
# Fetch all releases # Fetch all releases (paginated)
RELEASES=$(curl -sSL \ RELEASES=$(gh api --paginate \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \ -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 # Start building skills index
echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json
FIRST_SKILL=true FIRST_SKILL=true
PROCESSED_SKILLS="" declare -A PROCESSED_SKILLS=()
# Process each release (using process substitution to avoid subshell) # Process each release (using process substitution to avoid subshell)
while read -r release; do while read -r release; do
@@ -70,7 +71,7 @@ jobs:
VERSION="${BASH_REMATCH[2]}" VERSION="${BASH_REMATCH[2]}"
# Skip if we already processed a newer version of this skill # 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)" echo "Skipping older version: $TAG (already have newer)"
continue continue
fi fi
@@ -99,13 +100,16 @@ jobs:
continue continue
fi fi
# Mirror all release assets under a GitHub-compatible path so users can # Security: Download to temp directory first, verify signatures, then mirror to final location.
# swap the host (github.com → clawsec.prompt.security) if GitHub is blocked. # This ensures unverified releases never appear in public/releases or the skills catalog.
MIRROR_DIR="public/releases/download/${TAG}"
mkdir -p "$MIRROR_DIR"
mv "$SKILL_JSON_TMP" "$MIRROR_DIR/skill.json"
# 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 while read -r asset; do
ASSET_ID=$(echo "$asset" | jq -r '.id') ASSET_ID=$(echo "$asset" | jq -r '.id')
ASSET_NAME=$(echo "$asset" | jq -r '.name') ASSET_NAME=$(echo "$asset" | jq -r '.name')
@@ -121,16 +125,41 @@ jobs:
continue continue
fi fi
download_asset "$ASSET_ID" "$MIRROR_DIR/$ASSET_NAME" download_asset "$ASSET_ID" "$TEMP_DOWNLOAD_DIR/$ASSET_NAME"
echo " Mirrored: $ASSET_NAME" echo " Downloaded to temp: $ASSET_NAME"
done < <(echo "$release" | jq -c '.assets[]') 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) # Copy the subset needed for the site catalog (skill pages)
mkdir -p "public/skills/${SKILL_NAME}" mkdir -p "public/skills/${SKILL_NAME}"
cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json" cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json"
echo " Added to catalog: 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 if [ -f "$MIRROR_DIR/$file" ]; then
cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file" cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file"
echo " Added to catalog: $file" echo " Added to catalog: $file"
@@ -158,7 +187,7 @@ jobs:
echo "$SKILL_DATA" >> public/skills/index.json echo "$SKILL_DATA" >> public/skills/index.json
# Mark this skill as processed (track newest only) # Mark this skill as processed (track newest only)
PROCESSED_SKILLS="${PROCESSED_SKILLS}${SKILL_NAME}\n" PROCESSED_SKILLS["$SKILL_NAME"]=1
else else
echo " Warning: skill.json not found in release assets" echo " Warning: skill.json not found in release assets"
fi fi
@@ -179,35 +208,105 @@ jobs:
echo "=== Skills Directory ===" echo "=== Skills Directory ==="
ls -la public/skills/ 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 - name: Copy advisory feed to public
run: | run: |
set -euo pipefail
mkdir -p public/advisories mkdir -p public/advisories
cp advisories/feed.json public/advisories/feed.json cp advisories/feed.json public/advisories/feed.json
echo "Copied advisory feed to public/advisories/" echo "Copied advisory feed to public/advisories/"
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} 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 - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Get latest clawsec-suite release URL - name: Get latest clawsec-suite release URL
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
run: | run: |
LATEST_TAG=$(curl -sSL \ LATEST_TAG=$(
-H "Authorization: Bearer ${GITHUB_TOKEN}" \ gh api --paginate \
-H "Accept: application/vnd.github+json" \ -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${REPO}/releases?per_page=100" | \ "/repos/${REPO}/releases?per_page=100" \
jq -r '[.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty') | jq -r -s 'add // [] | [.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty'
)
if [ -n "$LATEST_TAG" ]; then if [ -n "$LATEST_TAG" ]; then
echo "Found latest clawsec-suite tag: $LATEST_TAG" echo "Found latest clawsec-suite tag: $LATEST_TAG"
@@ -229,12 +328,26 @@ jobs:
echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)" echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)"
fi 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 if [ -f "public/advisories/feed.json" ]; then
mkdir -p "$MIRROR_LATEST_DIR/advisories" 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/advisories/feed.json"
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json" cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json"
fi 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 else
echo "No clawsec-suite release found, using fallback" echo "No clawsec-suite release found, using fallback"
fi fi
@@ -251,7 +364,9 @@ jobs:
- name: Copy skills data to dist - name: Copy skills data to dist
run: | run: |
cp -r public/skills dist/skills 2>/dev/null || echo "No skills directory" 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" cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory"
echo "=== Dist contents ===" echo "=== Dist contents ==="
@@ -263,10 +378,10 @@ jobs:
run: touch dist/.nojekyll run: touch dist/.nojekyll
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v4 uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v4 uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with: with:
path: ./dist path: ./dist
@@ -280,4 +395,4 @@ jobs:
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v4 uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
+49 -5
View File
@@ -22,7 +22,9 @@ concurrency:
env: env:
FEED_PATH: advisories/feed.json FEED_PATH: advisories/feed.json
FEED_SIG_PATH: advisories/feed.json.sig
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
KEYWORDS: "OpenClaw clawdbot Moltbot" KEYWORDS: "OpenClaw clawdbot Moltbot"
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw" GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
@@ -31,7 +33,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 1 fetch-depth: 1
@@ -80,6 +82,7 @@ jobs:
- name: Fetch CVEs from NVD - name: Fetch CVEs from NVD
id: fetch id: fetch
run: | run: |
set -euo pipefail
mkdir -p tmp mkdir -p tmp
START_DATE="${{ steps.dates.outputs.start_date }}" START_DATE="${{ steps.dates.outputs.start_date }}"
@@ -90,6 +93,8 @@ jobs:
END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g') END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
echo "=== Fetching CVEs from NVD ===" echo "=== Fetching CVEs from NVD ==="
FAILED_KEYWORDS=()
# Fetch for each keyword # Fetch for each keyword
for KEYWORD in $KEYWORDS; do for KEYWORD in $KEYWORDS; do
@@ -99,11 +104,22 @@ jobs:
echo "URL: $URL" echo "URL: $URL"
# Fetch with retry logic # Fetch with retry logic
keyword_ok=false
last_http_code=""
for i in 1 2 3; do 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 if [ "$HTTP_CODE" = "200" ]; then
echo "Success for $KEYWORD" if jq -e . "tmp/nvd_${KEYWORD}.json" >/dev/null 2>&1; then
break 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 elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
echo "Rate limited, waiting 30s before retry $i..." echo "Rate limited, waiting 30s before retry $i..."
sleep 30 sleep 30
@@ -112,11 +128,21 @@ jobs:
sleep 5 sleep 5
fi fi
done 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 # NVD recommends 6 second delay between requests
sleep 6 sleep 6
done done
if [ "${#FAILED_KEYWORDS[@]}" -gt 0 ]; then
echo "::error::NVD fetch failed for keyword(s): ${FAILED_KEYWORDS[*]}"
exit 1
fi
echo "=== Fetch complete ===" echo "=== Fetch complete ==="
ls -la tmp/ ls -la tmp/
@@ -508,6 +534,22 @@ jobs:
exit 1 exit 1
fi 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 - name: Clean workspace for PR
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0' if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
run: | run: |
@@ -518,7 +560,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0' if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
id: create-pr id: create-pr
uses: peter-evans/create-pull-request@v7 uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
branch: automated/nvd-cve-update-${{ github.run_id }} 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 }} Poll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}
add-paths: | add-paths: |
${{ env.FEED_PATH }} ${{ env.FEED_PATH }}
${{ env.FEED_SIG_PATH }}
${{ env.SKILL_FEED_PATH }} ${{ env.SKILL_FEED_PATH }}
${{ env.SKILL_FEED_SIG_PATH }}
- name: Summary - name: Summary
run: | run: |
+217 -10
View File
@@ -26,7 +26,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -174,10 +174,21 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 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 - name: Run release dry-run for changed skills
env: env:
BASE_SHA: ${{ github.event.pull_request.base.sha }} BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -185,6 +196,83 @@ jobs:
run: | run: |
set -euo pipefail 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() { get_md_version() {
local md_file="$1" local md_file="$1"
awk ' awk '
@@ -263,6 +351,13 @@ jobs:
out_assets="${out_root}/release-assets" out_assets="${out_root}/release-assets"
mkdir -p "${out_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 --- # --- Stage SBOM files preserving directory structure ---
staging_dir="$(mktemp -d)" staging_dir="$(mktemp -d)"
inner_dir="${staging_dir}/${skill_name}" inner_dir="${staging_dir}/${skill_name}"
@@ -284,10 +379,28 @@ jobs:
cp "${json_path}" "${inner_dir}/skill.json" 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 --- # --- Create zip preserving directory structure ---
zip_name="${skill_name}-v${version}.zip" zip_name="${skill_name}-v${version}.zip"
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .) (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 --- # --- Generate checksums.json via jq ---
files_json="{}" files_json="{}"
while IFS= read -r file; do while IFS= read -r file; do
@@ -402,7 +515,7 @@ jobs:
echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}" echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}"
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate skill exists - name: Validate skill exists
run: | run: |
@@ -472,10 +585,79 @@ jobs:
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: 20 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 - name: Install clawhub CLI
if: steps.publishable.outputs.publishable == 'true' if: steps.publishable.outputs.publishable == 'true'
run: npm install -g clawhub run: npm install -g clawhub
@@ -597,6 +779,20 @@ jobs:
echo "=== Release assets ===" echo "=== Release assets ==="
ls -la 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 - name: Publish to ClawHub
if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != '' if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
run: | run: |
@@ -620,7 +816,7 @@ jobs:
--no-input --no-input
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}" name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
@@ -637,22 +833,33 @@ jobs:
**Manual download with verification:** **Manual download with verification:**
```bash ```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 }}/${{ 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.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 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 unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
``` ```
### Verification ### 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 ```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 ### Files
+167
View File
@@ -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 2448h.
### 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
+7 -5
View File
@@ -6,10 +6,6 @@
<div align="center"> <div align="center">
<p><strong>We are featured on Product Hunt - upvote us and help us spread the word.</strong></p>
<a href="https://www.producthunt.com/products/clawsec-by-prompt-security?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-clawsec-by-prompt-security-2" target="_blank" rel="noopener noreferrer"><img alt="ClawSec by Prompt Security - A Security Skill Suite for OpenClaw Agents | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1076044&amp;theme=light&amp;t=1770632815547"></a>
## Secure Your OpenClaw Bots with a Complete Security Skill Suite ## Secure Your OpenClaw Bots with a Complete Security Skill Suite
<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4> <h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4>
@@ -29,7 +25,7 @@
[![CI](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml) [![CI](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
[![Deploy Pages](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml) [![Deploy Pages](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
[![Poll NVD CVEs](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml) [![Poll NVD CVEs](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
[![Skill Release](https://github.com/prompt-security/clawsec/actions/workflows/skill-release.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/skill-release.yml)
</div> </div>
@@ -202,6 +198,12 @@ Each skill release includes:
- `SKILL.md` - Main skill documentation - `SKILL.md` - Main skill documentation
- Additional files from SBOM (scripts, configs, etc.) - 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 ## 🛠️ Offline Tools
+215
View File
@@ -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)
- `<release>/checksums.json`
- `<release>/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
-18
View File
@@ -220,24 +220,6 @@ export const Home: React.FC = () => {
</div> </div>
</section> </section>
<section className="text-center mb-12">
<p className="text-sm text-gray-400 mb-3">
We are featured on Product Hunt - upvote us and help us spread the word.
</p>
<a
href="https://www.producthunt.com/products/clawsec-by-prompt-security?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-clawsec-by-prompt-security-2"
target="_blank"
rel="noopener noreferrer"
className="inline-block"
>
<img
alt="ClawSec by Prompt Security - A Security Skill Suite for OpenClaw Agents | Product Hunt"
width="250"
height="54"
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1076044&theme=light&t=1770632815547"
/>
</a>
</section>
<Footer /> <Footer />
</div> </div>
+76
View File
@@ -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
```
+50 -9
View File
@@ -1,12 +1,12 @@
--- ---
name: clawsec-suite name: clawsec-suite
version: 0.0.9 version: 0.0.10
description: ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills. 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 homepage: https://clawsec.prompt.security
clawdis: clawdis:
emoji: "📦" emoji: "📦"
requires: requires:
bins: [curl, jq, shasum] bins: [curl, jq, shasum, openssl]
--- ---
# ClawSec Suite # ClawSec Suite
@@ -41,7 +41,7 @@ This means `clawsec-suite` can:
npx clawhub@latest install clawsec-suite npx clawhub@latest install clawsec-suite
``` ```
### Option B: Manual download with verification ### Option B: Manual download with signature + checksum verification
```bash ```bash
set -euo pipefail set -euo pipefail
@@ -56,14 +56,45 @@ DOWNLOAD_DIR="$TEMP_DIR/downloads"
trap 'rm -rf "$TEMP_DIR"' EXIT trap 'rm -rf "$TEMP_DIR"' EXIT
mkdir -p "$DOWNLOAD_DIR" 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" -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 if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
echo "ERROR: Invalid checksums.json format" >&2 echo "ERROR: Invalid checksums.json format" >&2
exit 1 exit 1
fi 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 DOWNLOAD_FAILED=0
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do 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")" 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 exit 1
fi fi
# 3) Install files using paths from checksums.json # 4) Install files using paths from checksums.json
while IFS= read -r file; do while IFS= read -r file; do
[ -z "$file" ] && continue [ -z "$file" ] && continue
REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")" 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 {} \; find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
echo "Installed clawsec-suite v${VERSION} to: $DEST" 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) ## OpenClaw Automation (Hook + Optional Cron)
@@ -147,6 +178,7 @@ node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --versio
Behavior: Behavior:
- If no advisory match is found, install proceeds. - 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`. - 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`: - 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: The embedded feed logic uses these defaults:
- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json` - Remote feed URL: `https://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 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` - State file: `~/.openclaw/clawsec-suite-feed-state.json`
- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`) - 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 ### Quick feed check
```bash ```bash
@@ -245,7 +284,9 @@ npx clawhub@latest install clawtributor
## Security Notes ## 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). - Keep advisory polling rate-limited (at least 5 minutes between checks).
- Treat `critical` and `high` advisories affecting installed skills as immediate action items. - 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. - 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.
@@ -24,7 +24,16 @@ and asks for user approval first.
## Optional Environment Variables ## Optional Environment Variables
- `CLAWSEC_FEED_URL`: override remote feed URL. - `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`: 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_SUITE_STATE_FILE`: override state file path.
- `CLAWSEC_INSTALL_ROOT`: override installed skills root. - `CLAWSEC_INSTALL_ROOT`: override installed skills root.
- `CLAWSEC_SUITE_DIR`: override clawsec-suite install path. - `CLAWSEC_SUITE_DIR`: override clawsec-suite install path.
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { uniqueStrings } from "./lib/utils.mjs"; 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 type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
import { loadState, persistState } from "./lib/state.ts"; import { loadState, persistState } from "./lib/state.ts";
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts"; import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
@@ -10,6 +10,7 @@ import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } fro
const DEFAULT_FEED_URL = const DEFAULT_FEED_URL =
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json"; "https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
const DEFAULT_SCAN_INTERVAL_SECONDS = 300; const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
let unsignedModeWarningShown = false;
function expandHome(inputPath: string): string { function expandHome(inputPath: string): string {
if (!inputPath) return inputPath; if (!inputPath) return inputPath;
@@ -49,16 +50,42 @@ function scannedRecently(lastScan: string | null, minIntervalSeconds: number): b
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000; return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
} }
async function loadFeed(feedUrl: string, localFeedPath: string): Promise<FeedPayload> { async function loadFeed(options: {
const remoteFeed = await loadRemoteFeed(feedUrl); feedUrl: string;
feedSignatureUrl: string;
feedChecksumsUrl: string;
feedChecksumsSignatureUrl: string;
localFeedPath: string;
localFeedSignaturePath: string;
localFeedChecksumsPath: string;
localFeedChecksumsSignaturePath: string;
feedPublicKeyPath: string;
allowUnsigned: boolean;
verifyChecksumManifest: boolean;
}): Promise<FeedPayload> {
const publicKeyPem = options.allowUnsigned ? "" : await fs.readFile(options.feedPublicKeyPath, "utf8");
const remoteFeed = await loadRemoteFeed(options.feedUrl, {
signatureUrl: options.feedSignatureUrl,
checksumsUrl: options.feedChecksumsUrl,
checksumsSignatureUrl: options.feedChecksumsSignatureUrl,
publicKeyPem,
checksumsPublicKeyPem: publicKeyPem,
allowUnsigned: options.allowUnsigned,
verifyChecksumManifest: options.verifyChecksumManifest,
});
if (remoteFeed) return remoteFeed; if (remoteFeed) return remoteFeed;
const fallbackRaw = await fs.readFile(localFeedPath, "utf8"); return await loadLocalFeed(options.localFeedPath, {
const fallbackPayload = JSON.parse(fallbackRaw); signaturePath: options.localFeedSignaturePath,
if (!isValidFeedPayload(fallbackPayload)) { checksumsPath: options.localFeedChecksumsPath,
throw new Error(`Invalid advisory feed format in fallback file: ${localFeedPath}`); checksumsSignaturePath: options.localFeedChecksumsSignaturePath,
} publicKeyPem,
return fallbackPayload; checksumsPublicKeyPem: publicKeyPem,
allowUnsigned: options.allowUnsigned,
verifyChecksumManifest: options.verifyChecksumManifest,
checksumPublicKeyEntry: path.basename(options.feedPublicKeyPath),
});
} }
const handler = async (event: HookEvent): Promise<void> => { const handler = async (event: HookEvent): Promise<void> => {
@@ -69,15 +96,41 @@ const handler = async (event: HookEvent): Promise<void> => {
); );
const suiteDir = expandHome(process.env.CLAWSEC_SUITE_DIR || path.join(installRoot, "clawsec-suite")); 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 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( const stateFile = expandHome(
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"), 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 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( const scanIntervalSeconds = parsePositiveInteger(
process.env.CLAWSEC_HOOK_INTERVAL_SECONDS, process.env.CLAWSEC_HOOK_INTERVAL_SECONDS,
DEFAULT_SCAN_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 forceScan = toEventName(event) === "command:new";
const state = await loadState(stateFile); const state = await loadState(stateFile);
if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) { if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
@@ -86,7 +139,19 @@ const handler = async (event: HookEvent): Promise<void> => {
let feed: FeedPayload; let feed: FeedPayload;
try { try {
feed = await loadFeed(feedUrl, localFeedPath); feed = await loadFeed({
feedUrl,
feedSignatureUrl,
feedChecksumsUrl,
feedChecksumsSignatureUrl,
localFeedPath,
localFeedSignaturePath,
localFeedChecksumsPath,
localFeedChecksumsSignaturePath,
feedPublicKeyPath,
allowUnsigned,
verifyChecksumManifest,
});
} catch (error) { } catch (error) {
console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`); console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`);
return; return;
@@ -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"; import { isObject } from "./utils.mjs";
/**
* Allowed domains for feed/signature fetching.
* Only connections to these domains are permitted for security.
*/
const ALLOWED_DOMAINS = [
"clawsec.prompt.security",
"prompt.security",
"raw.githubusercontent.com",
"github.com",
];
/**
* Custom error class for security policy violations.
* These errors should always propagate and never be silently caught.
*/
class SecurityPolicyError extends Error {
constructor(message) {
super(message);
this.name = "SecurityPolicyError";
}
}
/**
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
* @returns {https.Agent}
*/
function createSecureAgent() {
return new https.Agent({
// Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1)
minVersion: "TLSv1.2",
// Ensure certificate validation is enabled (reject unauthorized certificates)
rejectUnauthorized: true,
// Use strong cipher suites
ciphers: "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
});
}
/**
* Validates that a URL is from an allowed domain.
* @param {string} url
* @returns {boolean}
*/
function isAllowedDomain(url) {
try {
const parsed = new URL(url);
// Only allow HTTPS protocol
if (parsed.protocol !== "https:") {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Check if hostname matches any allowed domain
return ALLOWED_DOMAINS.some(
(allowed) =>
hostname === allowed || hostname.endsWith(`.${allowed}`)
);
} catch {
return false;
}
}
/**
* Secure wrapper around fetch with TLS enforcement and domain validation.
* @param {string} url
* @param {RequestInit} [options]
* @returns {Promise<Response>}
* @throws {SecurityPolicyError} If URL is not from an allowed domain
*/
async function secureFetch(url, options = {}) {
// Validate domain before making request
if (!isAllowedDomain(url)) {
throw new SecurityPolicyError(
`Security policy violation: URL domain not allowed. ` +
`Only connections to ${ALLOWED_DOMAINS.join(", ")} are permitted. ` +
`Blocked: ${url}`
);
}
// Use secure HTTPS agent with TLS 1.2+ enforcement
const agent = createSecureAgent();
return globalThis.fetch(url, {
...options,
// Attach secure agent for Node.js fetch
// @ts-ignore - agent is supported in Node.js fetch
agent,
});
}
/** /**
* @param {string} rawSpecifier * @param {string} rawSpecifier
* @returns {{ name: string; versionSpec: string } | null} * @returns {{ name: string; versionSpec: string } | null}
@@ -25,34 +120,392 @@ export function parseAffectedSpecifier(rawSpecifier) {
*/ */
export function isValidFeedPayload(raw) { export function isValidFeedPayload(raw) {
if (!isObject(raw)) return false; if (!isObject(raw)) return false;
if (typeof raw.version !== "string" || !raw.version.trim()) return false;
if (!Array.isArray(raw.advisories)) 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; return true;
} }
/** /**
* @param {string} feedUrl * @param {string} signatureRaw
* @returns {Promise<import("./types.ts").FeedPayload | null>} * @returns {Buffer | null}
*/ */
export async function loadRemoteFeed(feedUrl) { function decodeSignature(signatureRaw) {
const fetchFn = /** @type {{ fetch?: Function }} */ (globalThis).fetch; const trimmed = String(signatureRaw ?? "").trim();
if (typeof fetchFn !== "function") return null; if (!trimmed) return null;
let encoded = trimmed;
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (isObject(parsed) && typeof parsed.signature === "string") {
encoded = parsed.signature;
}
} catch {
return null;
}
}
const normalized = encoded.replace(/\s+/g, "");
if (!normalized) return null;
try {
return Buffer.from(normalized, "base64");
} catch {
return null;
}
}
/**
* @param {string} payloadRaw
* @param {string} signatureRaw
* @param {string} publicKeyPem
* @returns {boolean}
*/
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
const signature = decodeSignature(signatureRaw);
if (!signature) return false;
const keyPem = String(publicKeyPem ?? "").trim();
if (!keyPem) return false;
try {
const publicKey = crypto.createPublicKey(keyPem);
return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
} catch {
return false;
}
}
/**
* @param {string | Buffer} content
* @returns {string}
*/
function sha256Hex(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}
/**
* @param {unknown} value
* @returns {string | null}
*/
function extractSha256Value(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
if (isObject(value) && typeof value.sha256 === "string") {
const normalized = value.sha256.trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
}
return null;
}
/**
* @param {string} manifestRaw
* @returns {{ schemaVersion: string; algorithm: string; files: Record<string, string> }}
*/
function parseChecksumsManifest(manifestRaw) {
let parsed;
try {
parsed = JSON.parse(manifestRaw);
} catch {
throw new Error("Checksum manifest is not valid JSON");
}
if (!isObject(parsed)) {
throw new Error("Checksum manifest must be an object");
}
const algorithmRaw = typeof parsed.algorithm === "string" ? parsed.algorithm.trim().toLowerCase() : "sha256";
if (algorithmRaw !== "sha256") {
throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || "(empty)"}`);
}
// Support legacy manifest formats:
// - New standard: schema_version field
// - skill-release.yml: version field (e.g., "0.0.1")
// - deploy-pages.yml (pre-fix): generated_at field (e.g., "2026-02-08T...")
// - Ultimate fallback: "1"
const schemaVersion = (
typeof parsed.schema_version === "string" ? parsed.schema_version.trim() :
typeof parsed.version === "string" ? parsed.version.trim() :
typeof parsed.generated_at === "string" ? parsed.generated_at.trim() :
"1"
);
if (!schemaVersion) {
throw new Error("Checksum manifest missing schema_version");
}
if (!isObject(parsed.files)) {
throw new Error("Checksum manifest missing files object");
}
const files = /** @type {Record<string, string>} */ ({});
for (const [key, value] of Object.entries(parsed.files)) {
if (!String(key).trim()) continue;
const digest = extractSha256Value(value);
if (!digest) {
throw new Error(`Invalid checksum digest entry for ${key}`);
}
files[key] = digest;
}
if (Object.keys(files).length === 0) {
throw new Error("Checksum manifest has no usable file digests");
}
return {
schemaVersion,
algorithm: algorithmRaw,
files,
};
}
/**
* @param {{ files: Record<string, string> }} manifest
* @param {Record<string, string | Buffer>} expectedEntries
*/
function verifyChecksums(manifest, expectedEntries) {
for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
if (!entryName) continue;
const 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<string | null>}
*/
async function fetchText(fetchFn, targetUrl) {
const controller = new globalThis.AbortController(); const controller = new globalThis.AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 10000); const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
try { try {
const response = await fetchFn(feedUrl, { const response = await fetchFn(targetUrl, {
method: "GET", method: "GET",
signal: controller.signal, signal: controller.signal,
headers: { accept: "application/json" }, headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
}); });
if (!response.ok) return null; if (!response.ok) return null;
const payload = await response.json(); return await response.text();
if (!isValidFeedPayload(payload)) return null; } catch (error) {
return payload; // Re-throw security policy violations - these should never be silently caught
} catch { if (error instanceof SecurityPolicyError) {
throw error;
}
// Network errors, timeouts, etc. return null (graceful degradation)
return null; return null;
} finally { } finally {
globalThis.clearTimeout(timeout); globalThis.clearTimeout(timeout);
} }
} }
/**
* @param {string} feedPath
* @param {{
* signaturePath?: string;
* checksumsPath?: string;
* checksumsSignaturePath?: string;
* publicKeyPem?: string;
* checksumsPublicKeyPem?: string;
* allowUnsigned?: boolean;
* verifyChecksumManifest?: boolean;
* checksumFeedEntry?: string;
* checksumSignatureEntry?: string;
* checksumPublicKeyEntry?: string;
* }} [options]
* @returns {Promise<import("./types.ts").FeedPayload>}
*/
export async function loadLocalFeed(feedPath, options = {}) {
const signaturePath = options.signaturePath ?? `${feedPath}.sig`;
const checksumsPath = options.checksumsPath ?? path.join(path.dirname(feedPath), "checksums.json");
const checksumsSignaturePath = options.checksumsSignaturePath ?? `${checksumsPath}.sig`;
const publicKeyPem = String(options.publicKeyPem ?? "");
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
const allowUnsigned = options.allowUnsigned === true;
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
const payloadRaw = await fs.readFile(feedPath, "utf8");
if (!allowUnsigned) {
const signatureRaw = await fs.readFile(signaturePath, "utf8");
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
throw new Error(`Feed signature verification failed for local feed: ${feedPath}`);
}
if (verifyChecksumManifest) {
const checksumsRaw = await fs.readFile(checksumsPath, "utf8");
const checksumsSignatureRaw = await fs.readFile(checksumsSignaturePath, "utf8");
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
throw new Error(`Checksum manifest signature verification failed: ${checksumsPath}`);
}
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
const checksumFeedEntry = options.checksumFeedEntry ?? path.basename(feedPath);
const checksumSignatureEntry = options.checksumSignatureEntry ?? path.basename(signaturePath);
const expectedEntries = /** @type {Record<string, string>} */ ({
[checksumFeedEntry]: payloadRaw,
[checksumSignatureEntry]: signatureRaw,
});
if (options.checksumPublicKeyEntry) {
expectedEntries[options.checksumPublicKeyEntry] = publicKeyPem;
}
verifyChecksums(checksumsManifest, expectedEntries);
}
}
const payload = JSON.parse(payloadRaw);
if (!isValidFeedPayload(payload)) {
throw new Error(`Invalid advisory feed format: ${feedPath}`);
}
return payload;
}
/**
* @param {string} feedUrl
* @param {{
* signatureUrl?: string;
* checksumsUrl?: string;
* checksumsSignatureUrl?: string;
* publicKeyPem?: string;
* checksumsPublicKeyPem?: string;
* allowUnsigned?: boolean;
* verifyChecksumManifest?: boolean;
* checksumFeedEntry?: string;
* checksumSignatureEntry?: string;
* }} [options]
* @returns {Promise<import("./types.ts").FeedPayload | null>}
*/
export async function loadRemoteFeed(feedUrl, options = {}) {
// Use secure fetch with TLS 1.2+ enforcement and domain validation
const fetchFn = secureFetch;
if (typeof fetchFn !== "function") return null;
const signatureUrl = options.signatureUrl ?? `${feedUrl}.sig`;
const checksumsUrl = options.checksumsUrl ?? defaultChecksumsUrl(feedUrl);
const checksumsSignatureUrl = options.checksumsSignatureUrl ?? `${checksumsUrl}.sig`;
const publicKeyPem = String(options.publicKeyPem ?? "");
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
const allowUnsigned = options.allowUnsigned === true;
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
try {
const payloadRaw = await fetchText(fetchFn, feedUrl);
if (!payloadRaw) return null;
if (!allowUnsigned) {
const signatureRaw = await fetchText(fetchFn, signatureUrl);
if (!signatureRaw) return null;
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
return null;
}
// Only verify checksums if explicitly requested AND both checksum files are available.
// Note: Many upstream workflows (e.g., GitHub raw content) don't publish checksums.json,
// so we gracefully skip verification when these files are missing.
if (verifyChecksumManifest) {
const checksumsRaw = await fetchText(fetchFn, checksumsUrl);
const checksumsSignatureRaw = await fetchText(fetchFn, checksumsSignatureUrl);
// Only proceed if BOTH checksum files are present
if (checksumsRaw && checksumsSignatureRaw) {
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
return null; // Fail-closed: invalid signature
}
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
// Derive checksum entry names from actual URLs (supports any filename, not just feed.json)
const checksumFeedEntry = options.checksumFeedEntry ?? safeBasename(feedUrl, "feed.json");
const checksumSignatureEntry = options.checksumSignatureEntry ?? safeBasename(signatureUrl, "feed.json.sig");
verifyChecksums(checksumsManifest, {
[checksumFeedEntry]: payloadRaw,
[checksumSignatureEntry]: signatureRaw,
});
}
// If checksum files missing: continue without checksum verification
// (feed signature was already verified above at line 328)
}
}
try {
const payload = JSON.parse(payloadRaw);
if (!isValidFeedPayload(payload)) return null;
return payload;
} catch {
return null;
}
} catch (error) {
// Security policy violations (invalid URLs, non-HTTPS, disallowed domains) return null
// to allow graceful fallback to local feed
if (error instanceof SecurityPolicyError) {
return null;
}
// Re-throw unexpected errors
throw error;
}
}
@@ -17,6 +17,7 @@ export type Advisory = {
}; };
export type FeedPayload = { export type FeedPayload = {
version: string;
updated?: string; updated?: string;
advisories: Advisory[]; advisories: Advisory[];
}; };
@@ -0,0 +1,85 @@
#!/usr/bin/env node
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
const DEFAULT_FILES = ["feed-signing-public.pem", "feed.json", "feed.json.sig"];
function usage() {
process.stderr.write(
[
"Usage:",
" node scripts/generate_checksums_json.mjs --out advisories/checksums.json [--base advisories] [--file feed.json --file feed.json.sig ...]",
"",
"Defaults:",
" --base <dirname(--out)>",
` --file ${DEFAULT_FILES.join(" --file ")}`,
"",
].join("\n"),
);
}
function parseArgs(argv) {
const parsed = { files: [] };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--out") {
parsed.outPath = argv[++i];
} else if (token === "--base") {
parsed.baseDir = argv[++i];
} else if (token === "--file") {
parsed.files.push(argv[++i]);
} else if (token === "-h" || token === "--help") {
parsed.help = true;
} else {
throw new Error(`Unknown argument: ${token}`);
}
}
return parsed;
}
function sha256Hex(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
async function main() {
const { outPath, baseDir, files, help } = parseArgs(process.argv.slice(2));
if (help) {
usage();
process.exit(0);
}
if (!outPath) {
usage();
throw new Error("Missing required argument: --out");
}
const resolvedBase = path.resolve(baseDir ?? path.dirname(outPath));
const fileList = files.length > 0 ? files : DEFAULT_FILES;
const checksums = {};
for (const relativePath of [...fileList].sort((a, b) => a.localeCompare(b))) {
const absolutePath = path.resolve(resolvedBase, relativePath);
const content = await fs.readFile(absolutePath);
checksums[relativePath] = sha256Hex(content);
}
const payload = {
schema_version: "1.0",
algorithm: "sha256",
files: checksums,
};
await fs.writeFile(`${outPath}`, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
process.stdout.write(`Wrote ${outPath}\n`);
}
main().catch((error) => {
process.stderr.write(`${String(error)}\n`);
process.exit(1);
});
@@ -6,12 +6,21 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs"; import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.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 = const DEFAULT_FEED_URL =
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json"; "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_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 = 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; const EXIT_CONFIRM_REQUIRED = 42;
function printUsage() { function printUsage() {
@@ -87,12 +96,10 @@ function affectedSpecifierMatches(specifier, skillName, version) {
return versionMatches(version, parsed.versionSpec); return versionMatches(version, parsed.versionSpec);
} }
function affectedSpecifierMatchesNameOnly(specifier, skillName) { function affectedSpecifierMatchesWithoutVersion(specifier, skillName) {
const parsed = parseAffectedSpecifier(specifier); const parsed = parseAffectedSpecifier(specifier);
if (!parsed) return false; if (!parsed) return false;
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false; return normalizeSkillName(parsed.name) === normalizeSkillName(skillName);
const vs = parsed.versionSpec.trim();
return !vs || vs === "*" || vs.toLowerCase() === "any";
} }
function advisoryLooksHighRisk(advisory) { function advisoryLooksHighRisk(advisory) {
@@ -108,17 +115,47 @@ function advisoryLooksHighRisk(advisory) {
async function loadFeed() { async function loadFeed() {
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL; 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 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}` }; if (remoteFeed) return { feed: remoteFeed, source: `remote:${feedUrl}` };
const raw = await fs.readFile(localFeedPath, "utf8"); const localFeed = await loadLocalFeed(localFeedPath, {
const payload = JSON.parse(raw); signaturePath: localFeedSigPath,
if (!isValidFeedPayload(payload)) { checksumsPath: localFeedChecksumsPath,
throw new Error(`Invalid fallback advisory feed format: ${localFeedPath}`); checksumsSignaturePath: localFeedChecksumsSigPath,
} publicKeyPem,
return { feed: payload, source: `local:${localFeedPath}` }; checksumsPublicKeyPem: publicKeyPem,
allowUnsigned,
verifyChecksumManifest,
checksumPublicKeyEntry: path.basename(feedPublicKeyPath),
});
return { feed: localFeed, source: `local:${localFeedPath}` };
} }
function findMatches(feed, skillName, version) { function findMatches(feed, skillName, version) {
@@ -133,7 +170,7 @@ function findMatches(feed, skillName, version) {
affected.filter((specifier) => affected.filter((specifier) =>
version version
? affectedSpecifierMatches(specifier, skillName, 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`); 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) { if (matches.length > 0) {
printMatches(matches, args.skill, args.version); printMatches(matches, args.skill, args.version);
@@ -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 <private-key.pem> --in <file> --out <file.sig>",
"",
"Signs <file> with Ed25519 private key and writes base64 detached signature to <file.sig>.",
"",
].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);
});
@@ -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 <public-key.pem> --in <file> --sig <file.sig>",
"",
"Verifies Ed25519 detached signature against <file>.",
"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);
});
+73 -9
View File
@@ -1,7 +1,7 @@
{ {
"name": "clawsec-suite", "name": "clawsec-suite",
"version": "0.0.9", "version": "0.0.10",
"description": "ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.", "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", "author": "prompt-security",
"license": "MIT", "license": "MIT",
"homepage": "https://clawsec.prompt.security/", "homepage": "https://clawsec.prompt.security/",
@@ -19,7 +19,9 @@
"agents", "agents",
"ai", "ai",
"suite", "suite",
"openclaw" "openclaw",
"signature",
"verification"
], ],
"sbom": { "sbom": {
"files": [ "files": [
@@ -28,6 +30,11 @@
"required": true, "required": true,
"description": "Suite skill documentation and installation guide" "description": "Suite skill documentation and installation guide"
}, },
{
"path": "CHANGELOG.md",
"required": true,
"description": "Version history and security improvements changelog"
},
{ {
"path": "HEARTBEAT.md", "path": "HEARTBEAT.md",
"required": true, "required": true,
@@ -38,6 +45,26 @@
"required": true, "required": true,
"description": "Embedded advisory feed seed (merged from clawsec-feed)" "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", "path": "hooks/clawsec-advisory-guardian/HOOK.md",
"required": true, "required": true,
@@ -46,7 +73,7 @@
{ {
"path": "hooks/clawsec-advisory-guardian/handler.ts", "path": "hooks/clawsec-advisory-guardian/handler.ts",
"required": true, "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", "path": "hooks/clawsec-advisory-guardian/lib/utils.mjs",
@@ -61,7 +88,22 @@
{ {
"path": "hooks/clawsec-advisory-guardian/lib/feed.mjs", "path": "hooks/clawsec-advisory-guardian/lib/feed.mjs",
"required": true, "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", "path": "scripts/setup_advisory_hook.mjs",
@@ -76,7 +118,22 @@
{ {
"path": "scripts/guarded_skill_install.mjs", "path": "scripts/guarded_skill_install.mjs",
"required": true, "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_skill": "clawsec-feed",
"source_version": "0.0.4", "source_version": "0.0.4",
"paths": [ "paths": [
"advisories/feed.json" "advisories/feed.json",
"advisories/feed.json.sig",
"advisories/checksums.json",
"advisories/checksums.json.sig",
"advisories/feed-signing-public.pem"
], ],
"capabilities": [ "capabilities": [
"advisory-feed monitoring", "advisory-feed monitoring",
"new-advisory detection", "new-advisory detection",
"affected-skill cross-reference", "affected-skill cross-reference",
"approval-gated malicious-skill removal recommendations", "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, "standalone_available": true,
"deprecation_plan": "standalone skill may be retired after suite migration is verified" "deprecation_plan": "standalone skill may be retired after suite migration is verified"
@@ -153,7 +216,8 @@
"bins": [ "bins": [
"curl", "curl",
"jq", "jq",
"shasum" "shasum",
"openssl"
] ]
}, },
"triggers": [ "triggers": [
@@ -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);
});
@@ -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);
});