mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
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:
@@ -0,0 +1,110 @@
|
||||
name: Sign and Verify File
|
||||
description: Sign a file with the CI private key and verify the signature against one or more files.
|
||||
|
||||
inputs:
|
||||
private_key:
|
||||
description: PEM-encoded private key contents.
|
||||
required: true
|
||||
private_key_passphrase:
|
||||
description: Optional passphrase for encrypted private keys.
|
||||
required: false
|
||||
default: ""
|
||||
input_file:
|
||||
description: File to sign.
|
||||
required: true
|
||||
signature_file:
|
||||
description: Output path for base64 signature.
|
||||
required: true
|
||||
verify_files:
|
||||
description: Newline-separated list of files to verify with the generated signature. Defaults to input_file.
|
||||
required: false
|
||||
default: ""
|
||||
public_key_output:
|
||||
description: Optional output path for the derived public key.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
signature_file:
|
||||
description: Signature file path.
|
||||
value: ${{ steps.sign.outputs.signature_file }}
|
||||
public_key_file:
|
||||
description: Public key file path when public_key_output is set.
|
||||
value: ${{ steps.sign.outputs.public_key_file }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: sign
|
||||
shell: bash
|
||||
env:
|
||||
PRIVATE_KEY: ${{ inputs.private_key }}
|
||||
PRIVATE_KEY_PASSPHRASE: ${{ inputs.private_key_passphrase }}
|
||||
INPUT_FILE: ${{ inputs.input_file }}
|
||||
SIGNATURE_FILE: ${{ inputs.signature_file }}
|
||||
VERIFY_FILES_RAW: ${{ inputs.verify_files }}
|
||||
PUBLIC_KEY_OUTPUT: ${{ inputs.public_key_output }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${PRIVATE_KEY:-}" ]; then
|
||||
echo "::error::Missing required private key input."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${INPUT_FILE}" ]; then
|
||||
echo "::error file=${INPUT_FILE}::Input file not found for signing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
key_file="${tmp_dir}/private.pem"
|
||||
pub_file="${tmp_dir}/public.pem"
|
||||
sig_bin="${tmp_dir}/signature.bin"
|
||||
|
||||
printf '%s' "${PRIVATE_KEY}" > "${key_file}"
|
||||
|
||||
passin_args=()
|
||||
if [ -n "${PRIVATE_KEY_PASSPHRASE:-}" ]; then
|
||||
passin_args=(-passin "pass:${PRIVATE_KEY_PASSPHRASE}")
|
||||
fi
|
||||
|
||||
openssl pkey -in "${key_file}" "${passin_args[@]}" -pubout -out "${pub_file}"
|
||||
|
||||
mkdir -p "$(dirname "${SIGNATURE_FILE}")"
|
||||
# Sign with Ed25519 (requires -rawin flag for raw input)
|
||||
openssl pkeyutl -sign -rawin -inkey "${key_file}" "${passin_args[@]}" -in "${INPUT_FILE}" \
|
||||
| openssl base64 -A > "${SIGNATURE_FILE}"
|
||||
|
||||
openssl base64 -d -A -in "${SIGNATURE_FILE}" -out "${sig_bin}"
|
||||
|
||||
verify_files="${VERIFY_FILES_RAW}"
|
||||
if [ -z "${verify_files}" ]; then
|
||||
verify_files="${INPUT_FILE}"
|
||||
fi
|
||||
|
||||
while IFS= read -r verify_file; do
|
||||
[ -z "${verify_file}" ] && continue
|
||||
if [ ! -f "${verify_file}" ]; then
|
||||
echo "::error file=${verify_file}::Verification target does not exist."
|
||||
exit 1
|
||||
fi
|
||||
# Verify Ed25519 signature (requires -rawin flag for raw input)
|
||||
openssl pkeyutl -verify -rawin -pubin -inkey "${pub_file}" -sigfile "${sig_bin}" -in "${verify_file}" >/dev/null
|
||||
done <<< "${verify_files}"
|
||||
|
||||
if [ -n "${PUBLIC_KEY_OUTPUT}" ]; then
|
||||
mkdir -p "$(dirname "${PUBLIC_KEY_OUTPUT}")"
|
||||
cp "${pub_file}" "${PUBLIC_KEY_OUTPUT}"
|
||||
echo "public_key_file=${PUBLIC_KEY_OUTPUT}" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "public_key_file=" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
echo "signature_file=${SIGNATURE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||
@@ -0,0 +1,2 @@
|
||||
ruff==0.6.9
|
||||
bandit==1.7.9
|
||||
+27
-12
@@ -11,8 +11,8 @@ jobs:
|
||||
name: Lint TypeScript/React
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
@@ -28,12 +28,14 @@ jobs:
|
||||
name: Lint Python
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '.github/requirements-lint-python.txt'
|
||||
- name: Install linters
|
||||
run: pip install ruff bandit
|
||||
run: python -m pip install -r .github/requirements-lint-python.txt
|
||||
- name: Ruff (lint + format check)
|
||||
run: ruff check utils/ --output-format=github
|
||||
- name: Bandit (security)
|
||||
@@ -43,9 +45,9 @@ jobs:
|
||||
name: Lint Shell Scripts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: ShellCheck
|
||||
uses: ludeeus/action-shellcheck@master
|
||||
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
|
||||
with:
|
||||
scandir: './scripts'
|
||||
severity: warning
|
||||
@@ -54,9 +56,9 @@ jobs:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Trivy FS Scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
@@ -64,7 +66,7 @@ jobs:
|
||||
exit-code: '1'
|
||||
ignore-unfixed: true
|
||||
- name: Trivy Config Scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
||||
with:
|
||||
scan-type: 'config'
|
||||
scan-ref: '.'
|
||||
@@ -75,8 +77,8 @@ jobs:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
@@ -85,3 +87,16 @@ jobs:
|
||||
run: npm audit --audit-level=high --registry=https://registry.npmjs.org
|
||||
- name: Check for outdated deps
|
||||
run: npm outdated || true
|
||||
|
||||
clawsec-suite-tests:
|
||||
name: ClawSec Suite Verification Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Feed Verification Tests
|
||||
run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||
- name: Guarded Install Tests
|
||||
run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: community-advisory
|
||||
@@ -14,7 +15,9 @@ concurrency:
|
||||
|
||||
env:
|
||||
FEED_PATH: advisories/feed.json
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
|
||||
jobs:
|
||||
process-advisory:
|
||||
@@ -22,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -196,46 +199,80 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit changes
|
||||
- name: Sign advisory feed and verify
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: ${{ env.FEED_PATH }}
|
||||
signature_file: ${{ env.FEED_SIG_PATH }}
|
||||
verify_files: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
|
||||
git add "$FEED_PATH" "$SKILL_FEED_PATH"
|
||||
- name: Sync advisory signature to skill feed
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
|
||||
ADVISORY_ID="${{ steps.parse.outputs.advisory_id }}"
|
||||
git commit -m "chore: add community advisory $ADVISORY_ID
|
||||
- name: Create Pull Request
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: automated/community-advisory-${{ github.event.issue.number }}
|
||||
delete-branch: true
|
||||
title: "chore: add community advisory ${{ steps.parse.outputs.advisory_id }}"
|
||||
body: |
|
||||
## Summary
|
||||
Add community advisory `${{ steps.parse.outputs.advisory_id }}` from issue #${{ github.event.issue.number }}.
|
||||
|
||||
Added from issue #${{ github.event.issue.number }}
|
||||
Issue: ${{ github.event.issue.html_url }}"
|
||||
- Issue: ${{ github.event.issue.html_url }}
|
||||
- Reporter: @${{ github.event.issue.user.login }}
|
||||
- Trigger: `advisory-approved` label
|
||||
|
||||
git push
|
||||
---
|
||||
*This PR was generated by the community advisory workflow.*
|
||||
commit-message: |
|
||||
chore: add community advisory ${{ steps.parse.outputs.advisory_id }}
|
||||
|
||||
Added from issue #${{ github.event.issue.number }}
|
||||
Issue: ${{ github.event.issue.html_url }}
|
||||
add-paths: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.FEED_SIG_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
${{ env.SKILL_FEED_SIG_PATH }}
|
||||
|
||||
- name: Comment on issue
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
||||
const pullRequestUrl = '${{ steps.create-pr.outputs.pull-request-url }}';
|
||||
const operation = '${{ steps.create-pr.outputs.pull-request-operation }}';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `## Advisory Published
|
||||
body: `## Advisory Pull Request Opened
|
||||
|
||||
This security report has been published to the ClawSec advisory feed.
|
||||
This security report has been prepared for publication in the ClawSec advisory feed.
|
||||
|
||||
**Advisory ID:** \`${advisoryId}\`
|
||||
**Pull Request:** ${pullRequestUrl || 'No PR generated (no file changes detected)'}
|
||||
**PR Operation:** \`${operation || 'none'}\`
|
||||
|
||||
The advisory is now available in the feed and will be picked up by agents on their next feed check.
|
||||
The advisory will be published after the pull request is merged.
|
||||
|
||||
Thank you for your contribution to community security!`
|
||||
});
|
||||
|
||||
- name: Comment if already exists
|
||||
if: steps.parse.outputs.already_exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
||||
|
||||
@@ -23,10 +23,11 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Auto-discover skills from releases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
@@ -48,17 +49,17 @@ jobs:
|
||||
}
|
||||
export -f download_asset # Export for use in subshells (while loop)
|
||||
|
||||
# Fetch all releases
|
||||
RELEASES=$(curl -sSL \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
# Fetch all releases (paginated)
|
||||
RELEASES=$(gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${REPO}/releases?per_page=100")
|
||||
"/repos/${REPO}/releases?per_page=100" \
|
||||
| jq -s 'add // []')
|
||||
|
||||
# Start building skills index
|
||||
echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json
|
||||
|
||||
FIRST_SKILL=true
|
||||
PROCESSED_SKILLS=""
|
||||
declare -A PROCESSED_SKILLS=()
|
||||
|
||||
# Process each release (using process substitution to avoid subshell)
|
||||
while read -r release; do
|
||||
@@ -70,7 +71,7 @@ jobs:
|
||||
VERSION="${BASH_REMATCH[2]}"
|
||||
|
||||
# Skip if we already processed a newer version of this skill
|
||||
if echo "$PROCESSED_SKILLS" | grep -q "^${SKILL_NAME}$"; then
|
||||
if [[ -n "${PROCESSED_SKILLS[$SKILL_NAME]+x}" ]]; then
|
||||
echo "Skipping older version: $TAG (already have newer)"
|
||||
continue
|
||||
fi
|
||||
@@ -99,13 +100,16 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Mirror all release assets under a GitHub-compatible path so users can
|
||||
# swap the host (github.com → clawsec.prompt.security) if GitHub is blocked.
|
||||
MIRROR_DIR="public/releases/download/${TAG}"
|
||||
mkdir -p "$MIRROR_DIR"
|
||||
mv "$SKILL_JSON_TMP" "$MIRROR_DIR/skill.json"
|
||||
# Security: Download to temp directory first, verify signatures, then mirror to final location.
|
||||
# This ensures unverified releases never appear in public/releases or the skills catalog.
|
||||
|
||||
# Download all remaining assets for this release (retain asset names)
|
||||
# Use temp directory for downloads before verification
|
||||
TEMP_DOWNLOAD_DIR=$(mktemp -d)
|
||||
|
||||
# Move skill.json to temp dir first
|
||||
mv "$SKILL_JSON_TMP" "$TEMP_DOWNLOAD_DIR/skill.json"
|
||||
|
||||
# Download all remaining assets to temp dir
|
||||
while read -r asset; do
|
||||
ASSET_ID=$(echo "$asset" | jq -r '.id')
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
@@ -121,16 +125,41 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
download_asset "$ASSET_ID" "$MIRROR_DIR/$ASSET_NAME"
|
||||
echo " Mirrored: $ASSET_NAME"
|
||||
download_asset "$ASSET_ID" "$TEMP_DOWNLOAD_DIR/$ASSET_NAME"
|
||||
echo " Downloaded to temp: $ASSET_NAME"
|
||||
done < <(echo "$release" | jq -c '.assets[]')
|
||||
|
||||
# Verify signed checksums when signature artifacts are present.
|
||||
# Legacy releases without signatures are still mirrored for backward compatibility.
|
||||
if [ -f "$TEMP_DOWNLOAD_DIR/checksums.sig" ] && [ -f "$TEMP_DOWNLOAD_DIR/signing-public.pem" ] && [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
|
||||
openssl base64 -d -A -in "$TEMP_DOWNLOAD_DIR/checksums.sig" -out "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
|
||||
# Verify Ed25519 signature (requires -rawin)
|
||||
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$TEMP_DOWNLOAD_DIR/signing-public.pem" -sigfile "$TEMP_DOWNLOAD_DIR/checksums.sig.bin" -in "$TEMP_DOWNLOAD_DIR/checksums.json"; then
|
||||
echo " Warning: Invalid checksums signature for $TAG; skipping skill"
|
||||
rm -rf "$TEMP_DOWNLOAD_DIR"
|
||||
continue
|
||||
fi
|
||||
rm -f "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
|
||||
echo " Verified checksums signature"
|
||||
elif [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
|
||||
echo " Warning: Unsigned legacy checksums for $TAG (missing checksums.sig/signing-public.pem)"
|
||||
fi
|
||||
|
||||
# Verification passed or skipped (legacy) - mirror to final location
|
||||
MIRROR_DIR="public/releases/download/${TAG}"
|
||||
mkdir -p "$MIRROR_DIR"
|
||||
cp -r "$TEMP_DOWNLOAD_DIR"/* "$MIRROR_DIR"/
|
||||
echo " Mirrored to: $MIRROR_DIR"
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$TEMP_DOWNLOAD_DIR"
|
||||
|
||||
# Copy the subset needed for the site catalog (skill pages)
|
||||
mkdir -p "public/skills/${SKILL_NAME}"
|
||||
cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json"
|
||||
echo " Added to catalog: skill.json"
|
||||
|
||||
for file in checksums.json README.md SKILL.md; do
|
||||
for file in checksums.json checksums.sig signing-public.pem README.md SKILL.md; do
|
||||
if [ -f "$MIRROR_DIR/$file" ]; then
|
||||
cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file"
|
||||
echo " Added to catalog: $file"
|
||||
@@ -158,7 +187,7 @@ jobs:
|
||||
echo "$SKILL_DATA" >> public/skills/index.json
|
||||
|
||||
# Mark this skill as processed (track newest only)
|
||||
PROCESSED_SKILLS="${PROCESSED_SKILLS}${SKILL_NAME}\n"
|
||||
PROCESSED_SKILLS["$SKILL_NAME"]=1
|
||||
else
|
||||
echo " Warning: skill.json not found in release assets"
|
||||
fi
|
||||
@@ -179,35 +208,105 @@ jobs:
|
||||
echo "=== Skills Directory ==="
|
||||
ls -la public/skills/
|
||||
|
||||
- name: Create root checksums placeholder
|
||||
run: |
|
||||
# Create empty checksums.json placeholder for root level
|
||||
echo '{"version":"1.0.0","files":{}}' > public/checksums.json
|
||||
echo "Created checksums.json placeholder"
|
||||
|
||||
- name: Copy advisory feed to public
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p public/advisories
|
||||
cp advisories/feed.json public/advisories/feed.json
|
||||
echo "Copied advisory feed to public/advisories/"
|
||||
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
|
||||
|
||||
- name: Generate advisory checksums manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
FEED_FILE="public/advisories/feed.json"
|
||||
FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}')
|
||||
FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE")
|
||||
|
||||
# Generate checksums manifest conforming to parseChecksumsManifest expectations:
|
||||
# - schema_version: "1" (manifest format version)
|
||||
# - algorithm: "sha256" (hash algorithm)
|
||||
# - version: "1.1.0" (feed content version, for informational purposes)
|
||||
# - generated_at, repository: metadata
|
||||
# - files: map of path -> {sha256, size, path, url}
|
||||
jq -n \
|
||||
--arg schema_version "1" \
|
||||
--arg algorithm "sha256" \
|
||||
--arg version "1.1.0" \
|
||||
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg repo "${{ github.repository }}" \
|
||||
--arg sha "$FEED_SHA" \
|
||||
--argjson size "$FEED_SIZE" \
|
||||
'{
|
||||
schema_version: $schema_version,
|
||||
algorithm: $algorithm,
|
||||
version: $version,
|
||||
generated_at: $generated,
|
||||
repository: $repo,
|
||||
files: {
|
||||
"advisories/feed.json": {
|
||||
sha256: $sha,
|
||||
size: $size,
|
||||
path: "advisories/feed.json",
|
||||
url: "https://clawsec.prompt.security/advisories/feed.json"
|
||||
}
|
||||
}
|
||||
}' > public/checksums.json
|
||||
|
||||
echo "Generated public/checksums.json"
|
||||
jq . public/checksums.json
|
||||
|
||||
- name: Sign advisory feed and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: public/advisories/feed.json
|
||||
signature_file: public/advisories/feed.json.sig
|
||||
public_key_output: public/signing-public.pem
|
||||
|
||||
- name: Sign checksums and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: public/checksums.json
|
||||
signature_file: public/checksums.sig
|
||||
|
||||
- name: Copy public key to advisory directory
|
||||
run: |
|
||||
# Clients expect the public key at advisories/feed-signing-public.pem
|
||||
mkdir -p public/advisories
|
||||
cp public/signing-public.pem public/advisories/feed-signing-public.pem
|
||||
echo "Public key available at:"
|
||||
echo " - public/signing-public.pem (root)"
|
||||
echo " - public/advisories/feed-signing-public.pem (advisory-specific)"
|
||||
|
||||
- name: Show signed advisory artifacts
|
||||
run: |
|
||||
echo "Signed advisory artifacts:"
|
||||
ls -la public/advisories/feed.json*
|
||||
ls -la public/checksums.json public/checksums.sig public/signing-public.pem
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Get latest clawsec-suite release URL
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
LATEST_TAG=$(curl -sSL \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${REPO}/releases?per_page=100" | \
|
||||
jq -r '[.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty')
|
||||
LATEST_TAG=$(
|
||||
gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/${REPO}/releases?per_page=100" \
|
||||
| jq -r -s 'add // [] | [.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty'
|
||||
)
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
echo "Found latest clawsec-suite tag: $LATEST_TAG"
|
||||
@@ -229,12 +328,26 @@ jobs:
|
||||
echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)"
|
||||
fi
|
||||
|
||||
# Mirror advisories feed at the path referenced by suite docs/heartbeat
|
||||
# Mirror advisories feed + signatures at the path referenced by suite docs/heartbeat
|
||||
if [ -f "public/advisories/feed.json" ]; then
|
||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/advisories/feed.json"
|
||||
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json"
|
||||
fi
|
||||
if [ -f "public/advisories/feed.json.sig" ]; then
|
||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/advisories/feed.json.sig"
|
||||
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/feed.json.sig"
|
||||
fi
|
||||
if [ -f "public/checksums.json" ]; then
|
||||
cp "public/checksums.json" "$MIRROR_LATEST_DIR/checksums.json"
|
||||
fi
|
||||
if [ -f "public/checksums.sig" ]; then
|
||||
cp "public/checksums.sig" "$MIRROR_LATEST_DIR/checksums.sig"
|
||||
fi
|
||||
if [ -f "public/signing-public.pem" ]; then
|
||||
cp "public/signing-public.pem" "$MIRROR_LATEST_DIR/signing-public.pem"
|
||||
fi
|
||||
else
|
||||
echo "No clawsec-suite release found, using fallback"
|
||||
fi
|
||||
@@ -251,7 +364,9 @@ jobs:
|
||||
- name: Copy skills data to dist
|
||||
run: |
|
||||
cp -r public/skills dist/skills 2>/dev/null || echo "No skills directory"
|
||||
cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No legacy checksums"
|
||||
cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No checksums manifest"
|
||||
cp public/checksums.sig dist/checksums.sig 2>/dev/null || echo "No checksums signature"
|
||||
cp public/signing-public.pem dist/signing-public.pem 2>/dev/null || echo "No signing public key"
|
||||
cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory"
|
||||
|
||||
echo "=== Dist contents ==="
|
||||
@@ -263,10 +378,10 @@ jobs:
|
||||
run: touch dist/.nojekyll
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
|
||||
with:
|
||||
path: ./dist
|
||||
|
||||
@@ -280,4 +395,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
|
||||
@@ -22,7 +22,9 @@ concurrency:
|
||||
|
||||
env:
|
||||
FEED_PATH: advisories/feed.json
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
KEYWORDS: "OpenClaw clawdbot Moltbot"
|
||||
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
|
||||
|
||||
@@ -31,7 +33,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -80,6 +82,7 @@ jobs:
|
||||
- name: Fetch CVEs from NVD
|
||||
id: fetch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p tmp
|
||||
|
||||
START_DATE="${{ steps.dates.outputs.start_date }}"
|
||||
@@ -91,6 +94,8 @@ jobs:
|
||||
|
||||
echo "=== Fetching CVEs from NVD ==="
|
||||
|
||||
FAILED_KEYWORDS=()
|
||||
|
||||
# Fetch for each keyword
|
||||
for KEYWORD in $KEYWORDS; do
|
||||
echo "Fetching keyword: $KEYWORD"
|
||||
@@ -99,11 +104,22 @@ jobs:
|
||||
echo "URL: $URL"
|
||||
|
||||
# Fetch with retry logic
|
||||
keyword_ok=false
|
||||
last_http_code=""
|
||||
for i in 1 2 3; do
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL")
|
||||
HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL" || true)
|
||||
if [ -z "$HTTP_CODE" ]; then
|
||||
HTTP_CODE="000"
|
||||
fi
|
||||
last_http_code="$HTTP_CODE"
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Success for $KEYWORD"
|
||||
break
|
||||
if jq -e . "tmp/nvd_${KEYWORD}.json" >/dev/null 2>&1; then
|
||||
echo "Success for $KEYWORD"
|
||||
keyword_ok=true
|
||||
break
|
||||
fi
|
||||
echo "Invalid JSON for $KEYWORD, retry $i..."
|
||||
sleep 5
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
|
||||
echo "Rate limited, waiting 30s before retry $i..."
|
||||
sleep 30
|
||||
@@ -113,10 +129,20 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$keyword_ok" != "true" ]; then
|
||||
echo "::error::Failed to fetch valid NVD response for keyword '$KEYWORD' (last HTTP code: ${last_http_code:-unknown})."
|
||||
FAILED_KEYWORDS+=("$KEYWORD")
|
||||
fi
|
||||
|
||||
# NVD recommends 6 second delay between requests
|
||||
sleep 6
|
||||
done
|
||||
|
||||
if [ "${#FAILED_KEYWORDS[@]}" -gt 0 ]; then
|
||||
echo "::error::NVD fetch failed for keyword(s): ${FAILED_KEYWORDS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Fetch complete ==="
|
||||
ls -la tmp/
|
||||
|
||||
@@ -508,6 +534,22 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign advisory feed and verify
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: ${{ env.FEED_PATH }}
|
||||
signature_file: ${{ env.FEED_SIG_PATH }}
|
||||
verify_files: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
|
||||
- name: Sync advisory signature to skill feed
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
|
||||
- name: Clean workspace for PR
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
run: |
|
||||
@@ -518,7 +560,7 @@ jobs:
|
||||
- name: Create Pull Request
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: automated/nvd-cve-update-${{ github.run_id }}
|
||||
@@ -543,7 +585,9 @@ jobs:
|
||||
Poll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}
|
||||
add-paths: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.FEED_SIG_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
${{ env.SKILL_FEED_SIG_PATH }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -174,10 +174,21 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate test signing key for dry-run
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Generating temporary Ed25519 test key for dry-run validation"
|
||||
umask 077
|
||||
mkdir -p /tmp/test-signing
|
||||
# Use Ed25519 to match production signing (not RSA)
|
||||
openssl genpkey -algorithm ED25519 -out /tmp/test-signing/private.pem
|
||||
openssl pkey -in /tmp/test-signing/private.pem -pubout -out /tmp/test-signing/public.pem
|
||||
echo "TEST_SIGNING_KEY_DIR=/tmp/test-signing" >> $GITHUB_ENV
|
||||
|
||||
- name: Run release dry-run for changed skills
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -185,6 +196,83 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Helper function to sign advisory artifacts with test key (dry-run only)
|
||||
sign_advisory_artifacts() {
|
||||
local skill_dir="$1"
|
||||
local advisory_dir="${skill_dir}/advisories"
|
||||
|
||||
if [ ! -d "$advisory_dir" ] || [ ! -f "$advisory_dir/feed.json" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " [Dry-run] Signing advisory artifacts with test key"
|
||||
|
||||
local key_file="$TEST_SIGNING_KEY_DIR/private.pem"
|
||||
local pub_file="$TEST_SIGNING_KEY_DIR/public.pem"
|
||||
local tmp_sig_bin
|
||||
|
||||
# Sign feed.json with Ed25519 (requires -rawin flag)
|
||||
openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/feed.json" | \
|
||||
openssl base64 -A > "$advisory_dir/feed.json.sig"
|
||||
|
||||
# Verify Ed25519 feed.json signature (requires -rawin flag)
|
||||
tmp_sig_bin=$(mktemp)
|
||||
openssl base64 -d -A -in "$advisory_dir/feed.json.sig" -out "$tmp_sig_bin"
|
||||
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/feed.json" >/dev/null 2>&1; then
|
||||
echo "::error file=${skill_dir}/advisories/feed.json.sig::Feed signature verification failed after signing"
|
||||
rm -f "$tmp_sig_bin"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$tmp_sig_bin"
|
||||
echo " [Dry-run] Verified feed.json signature"
|
||||
|
||||
# Generate checksums.json
|
||||
local feed_sha=$(sha256sum "$advisory_dir/feed.json" | awk '{print $1}')
|
||||
local feed_size=$(stat -c%s "$advisory_dir/feed.json" 2>/dev/null || stat -f%z "$advisory_dir/feed.json")
|
||||
local feed_sig_sha=$(sha256sum "$advisory_dir/feed.json.sig" | awk '{print $1}')
|
||||
local feed_sig_size=$(stat -c%s "$advisory_dir/feed.json.sig" 2>/dev/null || stat -f%z "$advisory_dir/feed.json.sig")
|
||||
|
||||
jq -n \
|
||||
--arg schema_version "1" \
|
||||
--arg algorithm "sha256" \
|
||||
--arg version "test" \
|
||||
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg feed_sha "$feed_sha" \
|
||||
--argjson feed_size "$feed_size" \
|
||||
--arg feed_sig_sha "$feed_sig_sha" \
|
||||
--argjson feed_sig_size "$feed_sig_size" \
|
||||
'{
|
||||
schema_version: $schema_version,
|
||||
algorithm: $algorithm,
|
||||
version: $version,
|
||||
generated_at: $generated,
|
||||
files: {
|
||||
"advisories/feed.json": {sha256: $feed_sha, size: $feed_size},
|
||||
"advisories/feed.json.sig": {sha256: $feed_sig_sha, size: $feed_sig_size}
|
||||
}
|
||||
}' > "$advisory_dir/checksums.json"
|
||||
|
||||
# Sign checksums.json with Ed25519 (requires -rawin flag)
|
||||
openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/checksums.json" | \
|
||||
openssl base64 -A > "$advisory_dir/checksums.json.sig"
|
||||
|
||||
# Verify Ed25519 checksums.json signature (requires -rawin flag)
|
||||
tmp_sig_bin=$(mktemp)
|
||||
openssl base64 -d -A -in "$advisory_dir/checksums.json.sig" -out "$tmp_sig_bin"
|
||||
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/checksums.json" >/dev/null 2>&1; then
|
||||
echo "::error file=${skill_dir}/advisories/checksums.json.sig::Checksums signature verification failed after signing"
|
||||
rm -f "$tmp_sig_bin"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$tmp_sig_bin"
|
||||
echo " [Dry-run] Verified checksums.json signature"
|
||||
|
||||
# Copy public key
|
||||
cp "$pub_file" "$advisory_dir/feed-signing-public.pem"
|
||||
|
||||
echo " [Dry-run] Advisory artifacts signed and verified with test key"
|
||||
}
|
||||
|
||||
get_md_version() {
|
||||
local md_file="$1"
|
||||
awk '
|
||||
@@ -263,6 +351,13 @@ jobs:
|
||||
out_assets="${out_root}/release-assets"
|
||||
mkdir -p "${out_assets}"
|
||||
|
||||
# --- Sign advisory artifacts if present (dry-run with test key) ---
|
||||
if ! sign_advisory_artifacts "${skill_dir}"; then
|
||||
failures=$((failures + 1))
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
|
||||
# --- Stage SBOM files preserving directory structure ---
|
||||
staging_dir="$(mktemp -d)"
|
||||
inner_dir="${staging_dir}/${skill_name}"
|
||||
@@ -284,10 +379,28 @@ jobs:
|
||||
|
||||
cp "${json_path}" "${inner_dir}/skill.json"
|
||||
|
||||
# --- Remove test-only artifacts from staging (don't include in release zip) ---
|
||||
# The test signatures/keys were needed for SBOM validation but shouldn't ship
|
||||
if [ -d "${inner_dir}/advisories" ]; then
|
||||
rm -f "${inner_dir}/advisories/feed.json.sig"
|
||||
rm -f "${inner_dir}/advisories/checksums.json"
|
||||
rm -f "${inner_dir}/advisories/checksums.json.sig"
|
||||
rm -f "${inner_dir}/advisories/feed-signing-public.pem"
|
||||
echo " [Dry-run] Removed test signatures from release staging"
|
||||
fi
|
||||
|
||||
# --- Create zip preserving directory structure ---
|
||||
zip_name="${skill_name}-v${version}.zip"
|
||||
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .)
|
||||
|
||||
# --- Clean up test artifacts from source directory ---
|
||||
if [ -d "${skill_dir}/advisories" ]; then
|
||||
rm -f "${skill_dir}/advisories/feed.json.sig"
|
||||
rm -f "${skill_dir}/advisories/checksums.json"
|
||||
rm -f "${skill_dir}/advisories/checksums.json.sig"
|
||||
rm -f "${skill_dir}/advisories/feed-signing-public.pem"
|
||||
fi
|
||||
|
||||
# --- Generate checksums.json via jq ---
|
||||
files_json="{}"
|
||||
while IFS= read -r file; do
|
||||
@@ -402,7 +515,7 @@ jobs:
|
||||
echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Validate skill exists
|
||||
run: |
|
||||
@@ -472,10 +585,79 @@ jobs:
|
||||
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Sign embedded advisory feed and verify
|
||||
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json
|
||||
signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json.sig
|
||||
public_key_output: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed-signing-public.pem
|
||||
|
||||
- name: Generate embedded advisory checksums manifest
|
||||
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories"
|
||||
FEED_SHA=$(sha256sum "$ADVISORY_DIR/feed.json" | awk '{print $1}')
|
||||
FEED_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json")
|
||||
FEED_SIG_SHA=$(sha256sum "$ADVISORY_DIR/feed.json.sig" | awk '{print $1}')
|
||||
FEED_SIG_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json.sig" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json.sig")
|
||||
|
||||
jq -n \
|
||||
--arg schema_version "1" \
|
||||
--arg algorithm "sha256" \
|
||||
--arg version "${{ steps.parse.outputs.version }}" \
|
||||
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg repo "${{ github.repository }}" \
|
||||
--arg feed_sha "$FEED_SHA" \
|
||||
--argjson feed_size "$FEED_SIZE" \
|
||||
--arg feed_sig_sha "$FEED_SIG_SHA" \
|
||||
--argjson feed_sig_size "$FEED_SIG_SIZE" \
|
||||
'{
|
||||
schema_version: $schema_version,
|
||||
algorithm: $algorithm,
|
||||
version: $version,
|
||||
generated_at: $generated,
|
||||
repository: $repo,
|
||||
files: {
|
||||
"advisories/feed.json": {
|
||||
sha256: $feed_sha,
|
||||
size: $feed_size,
|
||||
path: "advisories/feed.json"
|
||||
},
|
||||
"advisories/feed.json.sig": {
|
||||
sha256: $feed_sig_sha,
|
||||
size: $feed_sig_size,
|
||||
path: "advisories/feed.json.sig"
|
||||
}
|
||||
}
|
||||
}' > "$ADVISORY_DIR/checksums.json"
|
||||
|
||||
echo "Generated $ADVISORY_DIR/checksums.json"
|
||||
jq . "$ADVISORY_DIR/checksums.json"
|
||||
|
||||
- name: Sign embedded advisory checksums and verify
|
||||
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json
|
||||
signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json.sig
|
||||
|
||||
- name: Show embedded advisory signing outputs
|
||||
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
|
||||
run: |
|
||||
ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories"
|
||||
echo "Successfully signed embedded advisory artifacts:"
|
||||
ls -la "$ADVISORY_DIR"
|
||||
|
||||
- name: Install clawhub CLI
|
||||
if: steps.publishable.outputs.publishable == 'true'
|
||||
run: npm install -g clawhub
|
||||
@@ -597,6 +779,20 @@ jobs:
|
||||
echo "=== Release assets ==="
|
||||
ls -la release-assets/
|
||||
|
||||
- name: Sign checksums and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: release-assets/checksums.json
|
||||
signature_file: release-assets/checksums.sig
|
||||
public_key_output: release-assets/signing-public.pem
|
||||
|
||||
- name: Show signed release assets
|
||||
run: |
|
||||
echo "Signed and verified release-assets/checksums.json"
|
||||
ls -la release-assets/
|
||||
|
||||
- name: Publish to ClawHub
|
||||
if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
|
||||
run: |
|
||||
@@ -620,7 +816,7 @@ jobs:
|
||||
--no-input
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -637,22 +833,33 @@ jobs:
|
||||
|
||||
**Manual download with verification:**
|
||||
```bash
|
||||
# 1. Download the release archive and checksums
|
||||
# 1. Download the release archive, checksums, and signing material
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
|
||||
|
||||
# 2. Verify archive checksum
|
||||
# 2. Verify the checksums manifest signature (Ed25519)
|
||||
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
|
||||
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
|
||||
|
||||
# 3. Verify archive checksum from the signed manifest
|
||||
echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c
|
||||
|
||||
# 3. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
|
||||
# 4. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
|
||||
unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
All files include SHA256 checksums in `checksums.json`:
|
||||
`checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key.
|
||||
Verify the signature first, then trust hashes from `checksums.json`:
|
||||
```bash
|
||||
curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json | jq .
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
|
||||
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
|
||||
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Migration Plan: Unsigned Feed → Signed Feed
|
||||
|
||||
## 1) Objective
|
||||
|
||||
Move ClawSec advisory distribution from unsigned `feed.json` delivery to detached-signature verification with minimal disruption.
|
||||
|
||||
This plan is written against the current repository behavior:
|
||||
- feed is produced by `poll-nvd-cves.yml` and `community-advisory.yml`
|
||||
- feed is published by `deploy-pages.yml`
|
||||
- suite consumers currently load unsigned JSON from remote/local fallback paths
|
||||
|
||||
## 2) Baseline (today)
|
||||
|
||||
Current feed paths in active use:
|
||||
- Source of truth: `advisories/feed.json`
|
||||
- Skill copy: `skills/clawsec-feed/advisories/feed.json`
|
||||
- Pages copy: `public/advisories/feed.json`
|
||||
- Latest mirror copy: `public/releases/latest/download/advisories/feed.json`
|
||||
|
||||
Current consumer defaults:
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||
- default URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
|
||||
## 3) Migration principles
|
||||
|
||||
- **Dual-publish first**: publish signatures before enforcing verification.
|
||||
- **Fail-open only during transition**: temporary compatibility period is explicit and time-bounded.
|
||||
- **Measured rollout**: enforce verification after telemetry confirms stable signed publishing.
|
||||
- **Fast rollback**: preserve a path back to unsigned behavior while root cause is investigated.
|
||||
|
||||
## 4) Phased timeline
|
||||
|
||||
### Phase 0 — Preparation (Week 0)
|
||||
|
||||
Deliverables:
|
||||
- signing keys generated and fingerprints recorded
|
||||
- GitHub secrets created
|
||||
- public key(s) added in repo
|
||||
- runbooks approved (`SECURITY-SIGNING.md`, this file)
|
||||
|
||||
Exit criteria:
|
||||
- key fingerprints verified by reviewer
|
||||
- protected branch/workflow controls enabled
|
||||
|
||||
### Phase 1 — CI signing enabled, no client enforcement (Week 1)
|
||||
|
||||
Implement:
|
||||
- add feed signing step/workflow to produce `advisories/feed.json.sig`
|
||||
- optionally produce `advisories/checksums.json` + `.sig`
|
||||
- ensure CI verifies signatures before publishing artifacts
|
||||
|
||||
Also update deployment:
|
||||
- copy `.sig` artifacts to `public/advisories/`
|
||||
- mirror `.sig` in `public/releases/latest/download/advisories/`
|
||||
|
||||
Exit criteria:
|
||||
- signatures generated successfully for all feed update paths
|
||||
- deploy artifacts contain both payload and signature companions
|
||||
|
||||
### Phase 2 — Consumer dual-read/dual-verify support (Week 2)
|
||||
|
||||
Implement in consumers:
|
||||
- read `feed.json` and `feed.json.sig`
|
||||
- verify with pinned public key
|
||||
- keep controlled temporary unsigned fallback during migration window
|
||||
|
||||
Validation:
|
||||
- test remote signed path
|
||||
- test local signed fallback path
|
||||
- test invalid signature rejection
|
||||
|
||||
Exit criteria:
|
||||
- verification logic released and tested
|
||||
- no false-positive verification failures in soak period
|
||||
|
||||
### Phase 3 — Enforcement (Week 3)
|
||||
|
||||
Actions:
|
||||
- disable temporary unsigned fallback behavior in default paths
|
||||
- add CI/publish gates that fail when `.sig` is missing
|
||||
- announce enforcement date in release notes and docs
|
||||
|
||||
Exit criteria:
|
||||
- all production clients verify signatures by default
|
||||
- no unsigned feed dependency in standard installation flow
|
||||
|
||||
### Phase 4 — Stabilization (Week 4)
|
||||
|
||||
Actions:
|
||||
- run first key rotation tabletop drill
|
||||
- run rollback tabletop drill
|
||||
- close migration with post-implementation review
|
||||
|
||||
## 5) Rollback plan
|
||||
|
||||
### Rollback triggers
|
||||
|
||||
Initiate rollback if any of the following occur:
|
||||
- sustained signature verification failures across clients
|
||||
- signing workflow cannot produce valid signatures
|
||||
- key compromise suspected but replacement key is not yet deployed
|
||||
- deployment path publishes mismatched payload/signature pairs
|
||||
|
||||
### Rollback levels
|
||||
|
||||
### Level 1 (preferred): Verification bypass window, keep signed publishing
|
||||
|
||||
Use when: signing is healthy, client-side verifier has a defect.
|
||||
|
||||
Actions:
|
||||
1. Re-enable temporary unsigned-acceptance behavior in client release branch.
|
||||
2. Ship patch release with explicit expiry date for bypass.
|
||||
3. Keep signing pipeline active to avoid authenticity gap.
|
||||
|
||||
Recovery target: restore strict verification within 24–48h.
|
||||
|
||||
### Level 2: Signed pipeline paused, unsigned feed temporarily authoritative
|
||||
|
||||
Use when: signing pipeline is unstable or producing inconsistent artifacts.
|
||||
|
||||
Actions:
|
||||
1. Disable signing workflow or signing step.
|
||||
2. Continue publishing unsigned `advisories/feed.json` via existing workflows.
|
||||
3. Revert deploy gates that require `.sig` artifacts.
|
||||
4. Open incident record and track time in unsigned mode.
|
||||
|
||||
Recovery target: restore signed publishing ASAP, ideally <72h.
|
||||
|
||||
### Level 3: Full release freeze
|
||||
|
||||
Use when: compromise or integrity of repository/workflows is in doubt.
|
||||
|
||||
Actions:
|
||||
1. Pause feed mutation and deployment workflows.
|
||||
2. Restore known-good commit for advisory files/workflows.
|
||||
3. Rotate keys and credentials.
|
||||
4. Resume pipeline only after security review sign-off.
|
||||
|
||||
### Roll-forward after rollback
|
||||
|
||||
- identify root cause
|
||||
- add regression tests/gates
|
||||
- redeploy signed artifacts
|
||||
- publish incident + remediation summary
|
||||
|
||||
## 6) Communication plan
|
||||
|
||||
For enforcement and rollback events, communicate:
|
||||
- what changed
|
||||
- expected operator/client action
|
||||
- duration of temporary compatibility mode (if any)
|
||||
- verification commands for users
|
||||
|
||||
Recommended channels:
|
||||
- GitHub release notes
|
||||
- repository README/docs updates
|
||||
- issue/incident report in repository
|
||||
|
||||
## 7) Go/No-Go checklist
|
||||
|
||||
Go only if all are true:
|
||||
- signing workflow success rate is stable
|
||||
- signatures are mirrored to all documented feed endpoints
|
||||
- consumer verification path tested for remote + local fallback
|
||||
- rollback owner is assigned and reachable
|
||||
- key rotation procedure has been dry-run at least once
|
||||
@@ -6,10 +6,6 @@
|
||||
|
||||
<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&utm_source=badge-featured&utm_medium=badge&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&theme=light&t=1770632815547"></a>
|
||||
|
||||
## 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>
|
||||
@@ -29,7 +25,7 @@
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/skill-release.yml)
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -202,6 +198,12 @@ Each skill release includes:
|
||||
- `SKILL.md` - Main skill documentation
|
||||
- Additional files from SBOM (scripts, configs, etc.)
|
||||
|
||||
### Signing Operations Documentation
|
||||
|
||||
For feed/release signing rollout and operations guidance:
|
||||
- [`SECURITY-SIGNING.md`](SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
|
||||
- [`MIGRATION-SIGNED-FEED.md`](MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Offline Tools
|
||||
|
||||
@@ -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
|
||||
@@ -220,24 +220,6 @@ export const Home: React.FC = () => {
|
||||
</div>
|
||||
</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 />
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: clawsec-suite
|
||||
version: 0.0.9
|
||||
description: ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.
|
||||
version: 0.0.10
|
||||
description: ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "📦"
|
||||
requires:
|
||||
bins: [curl, jq, shasum]
|
||||
bins: [curl, jq, shasum, openssl]
|
||||
---
|
||||
|
||||
# ClawSec Suite
|
||||
@@ -41,7 +41,7 @@ This means `clawsec-suite` can:
|
||||
npx clawhub@latest install clawsec-suite
|
||||
```
|
||||
|
||||
### Option B: Manual download with verification
|
||||
### Option B: Manual download with signature + checksum verification
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
@@ -56,14 +56,45 @@ DOWNLOAD_DIR="$TEMP_DIR/downloads"
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
mkdir -p "$DOWNLOAD_DIR"
|
||||
|
||||
# 1) Download checksums manifest
|
||||
# Pinned release-signing public key (verify fingerprint out-of-band on first use)
|
||||
# Fingerprint (SHA-256 of SPKI DER): 35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854
|
||||
RELEASE_PUBKEY_SHA256="35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854"
|
||||
cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM'
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAtaRGONGp0Syl9EBS17hEYgGTwUtfZgigklS6vAe5MlQ=
|
||||
-----END PUBLIC KEY-----
|
||||
PEM
|
||||
|
||||
ACTUAL_KEY_SHA256="$(openssl pkey -pubin -in "$TEMP_DIR/release-signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
|
||||
if [ "$ACTUAL_KEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
|
||||
echo "ERROR: Release public key fingerprint mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1) Download checksums manifest + detached signature
|
||||
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
|
||||
curl -fsSL "$BASE/checksums.json.sig" -o "$TEMP_DIR/checksums.json.sig"
|
||||
|
||||
# 2) Verify checksums manifest signature before trusting any file URLs or hashes
|
||||
openssl base64 -d -A -in "$TEMP_DIR/checksums.json.sig" -out "$TEMP_DIR/checksums.json.sig.bin"
|
||||
if ! openssl pkeyutl -verify \
|
||||
-pubin \
|
||||
-inkey "$TEMP_DIR/release-signing-public.pem" \
|
||||
-sigfile "$TEMP_DIR/checksums.json.sig.bin" \
|
||||
-rawin \
|
||||
-in "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: checksums.json signature verification failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: Invalid checksums.json format" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) Download every file listed in checksums and verify immediately
|
||||
echo "Checksums manifest signature verified."
|
||||
|
||||
# 3) Download every file listed in checksums and verify immediately
|
||||
DOWNLOAD_FAILED=0
|
||||
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
|
||||
FILE_URL="$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")"
|
||||
@@ -94,7 +125,7 @@ if [ "$DOWNLOAD_FAILED" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3) Install files using paths from checksums.json
|
||||
# 4) Install files using paths from checksums.json
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")"
|
||||
@@ -109,7 +140,7 @@ chmod 600 "$DEST/skill.json"
|
||||
find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||
|
||||
echo "Installed clawsec-suite v${VERSION} to: $DEST"
|
||||
echo "Next step (OpenClaw): node \"$DEST/scripts/setup_advisory_hook.mjs\""
|
||||
echo "Next step (OpenClaw): node \"\$DEST/scripts/setup_advisory_hook.mjs\""
|
||||
```
|
||||
|
||||
## OpenClaw Automation (Hook + Optional Cron)
|
||||
@@ -147,6 +178,7 @@ node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --versio
|
||||
|
||||
Behavior:
|
||||
- If no advisory match is found, install proceeds.
|
||||
- If `--version` is omitted, matching is conservative: any advisory that references the skill name is treated as a match.
|
||||
- If advisory match is found, the script prints advisory context and exits with code `42`.
|
||||
- Then require an explicit second confirmation from the user and rerun with `--confirm-advisory`:
|
||||
|
||||
@@ -163,10 +195,17 @@ This enforces:
|
||||
The embedded feed logic uses these defaults:
|
||||
|
||||
- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
- Remote feed signature URL: `${CLAWSEC_FEED_URL}.sig` (override with `CLAWSEC_FEED_SIG_URL`)
|
||||
- Remote checksums manifest URL: sibling `checksums.json` (override with `CLAWSEC_FEED_CHECKSUMS_URL`)
|
||||
- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json`
|
||||
- Local feed signature: `${CLAWSEC_LOCAL_FEED}.sig` (override with `CLAWSEC_LOCAL_FEED_SIG`)
|
||||
- Local checksums manifest: `~/.openclaw/skills/clawsec-suite/advisories/checksums.json`
|
||||
- Pinned feed signing key: `~/.openclaw/skills/clawsec-suite/advisories/feed-signing-public.pem` (override with `CLAWSEC_FEED_PUBLIC_KEY`)
|
||||
- State file: `~/.openclaw/clawsec-suite-feed-state.json`
|
||||
- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`)
|
||||
|
||||
**Fail-closed verification:** Both signature and checksum manifest verification are required by default. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream.
|
||||
|
||||
### Quick feed check
|
||||
|
||||
```bash
|
||||
@@ -245,7 +284,9 @@ npx clawhub@latest install clawtributor
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Always verify checksums before installing files manually.
|
||||
- Always verify `checksums.json` signature before trusting its file URLs/hashes, then verify each file checksum.
|
||||
- Verify advisory feed detached signatures; do not enable `CLAWSEC_ALLOW_UNSIGNED_FEED` outside temporary migration windows.
|
||||
- Keep advisory polling rate-limited (at least 5 minutes between checks).
|
||||
- Treat `critical` and `high` advisories affecting installed skills as immediate action items.
|
||||
- If you migrate off standalone `clawsec-feed`, keep one canonical state file to avoid duplicate notifications.
|
||||
- Pin and verify public key fingerprints out-of-band before first use.
|
||||
|
||||
@@ -24,7 +24,16 @@ and asks for user approval first.
|
||||
## Optional Environment Variables
|
||||
|
||||
- `CLAWSEC_FEED_URL`: override remote feed URL.
|
||||
- `CLAWSEC_FEED_SIG_URL`: override detached remote feed signature URL (default `${CLAWSEC_FEED_URL}.sig`).
|
||||
- `CLAWSEC_FEED_CHECKSUMS_URL`: override remote checksum manifest URL (default sibling `checksums.json`).
|
||||
- `CLAWSEC_FEED_CHECKSUMS_SIG_URL`: override detached remote checksum manifest signature URL.
|
||||
- `CLAWSEC_FEED_PUBLIC_KEY`: path to pinned feed-signing public key PEM.
|
||||
- `CLAWSEC_LOCAL_FEED`: override local fallback feed file.
|
||||
- `CLAWSEC_LOCAL_FEED_SIG`: override local detached feed signature path.
|
||||
- `CLAWSEC_LOCAL_FEED_CHECKSUMS`: override local checksum manifest path.
|
||||
- `CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG`: override local checksum manifest signature path.
|
||||
- `CLAWSEC_VERIFY_CHECKSUM_MANIFEST`: set to `0` only for emergency troubleshooting (default verifies checksums).
|
||||
- `CLAWSEC_ALLOW_UNSIGNED_FEED`: set to `1` only for temporary migration compatibility; bypasses signature/checksum verification.
|
||||
- `CLAWSEC_SUITE_STATE_FILE`: override state file path.
|
||||
- `CLAWSEC_INSTALL_ROOT`: override installed skills root.
|
||||
- `CLAWSEC_SUITE_DIR`: override clawsec-suite install path.
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { uniqueStrings } from "./lib/utils.mjs";
|
||||
import { isValidFeedPayload, loadRemoteFeed } from "./lib/feed.mjs";
|
||||
import { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.mjs";
|
||||
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
|
||||
import { loadState, persistState } from "./lib/state.ts";
|
||||
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
|
||||
@@ -10,6 +10,7 @@ import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } fro
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
||||
let unsignedModeWarningShown = false;
|
||||
|
||||
function expandHome(inputPath: string): string {
|
||||
if (!inputPath) return inputPath;
|
||||
@@ -49,16 +50,42 @@ function scannedRecently(lastScan: string | null, minIntervalSeconds: number): b
|
||||
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
|
||||
}
|
||||
|
||||
async function loadFeed(feedUrl: string, localFeedPath: string): Promise<FeedPayload> {
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl);
|
||||
async function loadFeed(options: {
|
||||
feedUrl: string;
|
||||
feedSignatureUrl: string;
|
||||
feedChecksumsUrl: string;
|
||||
feedChecksumsSignatureUrl: string;
|
||||
localFeedPath: string;
|
||||
localFeedSignaturePath: string;
|
||||
localFeedChecksumsPath: string;
|
||||
localFeedChecksumsSignaturePath: string;
|
||||
feedPublicKeyPath: string;
|
||||
allowUnsigned: boolean;
|
||||
verifyChecksumManifest: boolean;
|
||||
}): Promise<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;
|
||||
|
||||
const fallbackRaw = await fs.readFile(localFeedPath, "utf8");
|
||||
const fallbackPayload = JSON.parse(fallbackRaw);
|
||||
if (!isValidFeedPayload(fallbackPayload)) {
|
||||
throw new Error(`Invalid advisory feed format in fallback file: ${localFeedPath}`);
|
||||
}
|
||||
return fallbackPayload;
|
||||
return await loadLocalFeed(options.localFeedPath, {
|
||||
signaturePath: options.localFeedSignaturePath,
|
||||
checksumsPath: options.localFeedChecksumsPath,
|
||||
checksumsSignaturePath: options.localFeedChecksumsSignaturePath,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned: options.allowUnsigned,
|
||||
verifyChecksumManifest: options.verifyChecksumManifest,
|
||||
checksumPublicKeyEntry: path.basename(options.feedPublicKeyPath),
|
||||
});
|
||||
}
|
||||
|
||||
const handler = async (event: HookEvent): Promise<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 localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json"));
|
||||
const localFeedSignaturePath = expandHome(
|
||||
process.env.CLAWSEC_LOCAL_FEED_SIG || `${localFeedPath}.sig`,
|
||||
);
|
||||
const localFeedChecksumsPath = expandHome(
|
||||
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || path.join(path.dirname(localFeedPath), "checksums.json"),
|
||||
);
|
||||
const localFeedChecksumsSignaturePath = expandHome(
|
||||
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || `${localFeedChecksumsPath}.sig`,
|
||||
);
|
||||
const feedPublicKeyPath = expandHome(
|
||||
process.env.CLAWSEC_FEED_PUBLIC_KEY || path.join(suiteDir, "advisories", "feed-signing-public.pem"),
|
||||
);
|
||||
const stateFile = expandHome(
|
||||
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
|
||||
);
|
||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||
const feedChecksumsSignatureUrl =
|
||||
process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||
const scanIntervalSeconds = parsePositiveInteger(
|
||||
process.env.CLAWSEC_HOOK_INTERVAL_SECONDS,
|
||||
DEFAULT_SCAN_INTERVAL_SECONDS,
|
||||
);
|
||||
|
||||
if (allowUnsigned && !unsignedModeWarningShown) {
|
||||
unsignedModeWarningShown = true;
|
||||
console.warn(
|
||||
"[clawsec-advisory-guardian] CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. " +
|
||||
"This bypass is temporary migration compatibility and should be removed as soon as signed feed artifacts are available.",
|
||||
);
|
||||
}
|
||||
|
||||
const forceScan = toEventName(event) === "command:new";
|
||||
const state = await loadState(stateFile);
|
||||
if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
|
||||
@@ -86,7 +139,19 @@ const handler = async (event: HookEvent): Promise<void> => {
|
||||
|
||||
let feed: FeedPayload;
|
||||
try {
|
||||
feed = await loadFeed(feedUrl, localFeedPath);
|
||||
feed = await loadFeed({
|
||||
feedUrl,
|
||||
feedSignatureUrl,
|
||||
feedChecksumsUrl,
|
||||
feedChecksumsSignatureUrl,
|
||||
localFeedPath,
|
||||
localFeedSignaturePath,
|
||||
localFeedChecksumsPath,
|
||||
localFeedChecksumsSignaturePath,
|
||||
feedPublicKeyPath,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,100 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import https from "node:https";
|
||||
import path from "node:path";
|
||||
import { isObject } from "./utils.mjs";
|
||||
|
||||
/**
|
||||
* Allowed domains for feed/signature fetching.
|
||||
* Only connections to these domains are permitted for security.
|
||||
*/
|
||||
const ALLOWED_DOMAINS = [
|
||||
"clawsec.prompt.security",
|
||||
"prompt.security",
|
||||
"raw.githubusercontent.com",
|
||||
"github.com",
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom error class for security policy violations.
|
||||
* These errors should always propagate and never be silently caught.
|
||||
*/
|
||||
class SecurityPolicyError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "SecurityPolicyError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
|
||||
* @returns {https.Agent}
|
||||
*/
|
||||
function createSecureAgent() {
|
||||
return new https.Agent({
|
||||
// Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1)
|
||||
minVersion: "TLSv1.2",
|
||||
// Ensure certificate validation is enabled (reject unauthorized certificates)
|
||||
rejectUnauthorized: true,
|
||||
// Use strong cipher suites
|
||||
ciphers: "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL is from an allowed domain.
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAllowedDomain(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow HTTPS protocol
|
||||
if (parsed.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Check if hostname matches any allowed domain
|
||||
return ALLOWED_DOMAINS.some(
|
||||
(allowed) =>
|
||||
hostname === allowed || hostname.endsWith(`.${allowed}`)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure wrapper around fetch with TLS enforcement and domain validation.
|
||||
* @param {string} url
|
||||
* @param {RequestInit} [options]
|
||||
* @returns {Promise<Response>}
|
||||
* @throws {SecurityPolicyError} If URL is not from an allowed domain
|
||||
*/
|
||||
async function secureFetch(url, options = {}) {
|
||||
// Validate domain before making request
|
||||
if (!isAllowedDomain(url)) {
|
||||
throw new SecurityPolicyError(
|
||||
`Security policy violation: URL domain not allowed. ` +
|
||||
`Only connections to ${ALLOWED_DOMAINS.join(", ")} are permitted. ` +
|
||||
`Blocked: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
// Use secure HTTPS agent with TLS 1.2+ enforcement
|
||||
const agent = createSecureAgent();
|
||||
|
||||
return globalThis.fetch(url, {
|
||||
...options,
|
||||
// Attach secure agent for Node.js fetch
|
||||
// @ts-ignore - agent is supported in Node.js fetch
|
||||
agent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawSpecifier
|
||||
* @returns {{ name: string; versionSpec: string } | null}
|
||||
@@ -25,34 +120,392 @@ export function parseAffectedSpecifier(rawSpecifier) {
|
||||
*/
|
||||
export function isValidFeedPayload(raw) {
|
||||
if (!isObject(raw)) return false;
|
||||
if (typeof raw.version !== "string" || !raw.version.trim()) return false;
|
||||
if (!Array.isArray(raw.advisories)) return false;
|
||||
|
||||
for (const advisory of raw.advisories) {
|
||||
if (!isObject(advisory)) return false;
|
||||
if (typeof advisory.id !== "string" || !advisory.id.trim()) return false;
|
||||
if (typeof advisory.severity !== "string" || !advisory.severity.trim()) return false;
|
||||
if (!Array.isArray(advisory.affected)) return false;
|
||||
if (!advisory.affected.every((entry) => typeof entry === "string" && entry.trim())) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedUrl
|
||||
* @returns {Promise<import("./types.ts").FeedPayload | null>}
|
||||
* @param {string} signatureRaw
|
||||
* @returns {Buffer | null}
|
||||
*/
|
||||
export async function loadRemoteFeed(feedUrl) {
|
||||
const fetchFn = /** @type {{ fetch?: Function }} */ (globalThis).fetch;
|
||||
if (typeof fetchFn !== "function") return null;
|
||||
function decodeSignature(signatureRaw) {
|
||||
const trimmed = String(signatureRaw ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let encoded = trimmed;
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (isObject(parsed) && typeof parsed.signature === "string") {
|
||||
encoded = parsed.signature;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = encoded.replace(/\s+/g, "");
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
return Buffer.from(normalized, "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} payloadRaw
|
||||
* @param {string} signatureRaw
|
||||
* @param {string} publicKeyPem
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
if (!signature) return false;
|
||||
|
||||
const keyPem = String(publicKeyPem ?? "").trim();
|
||||
if (!keyPem) return false;
|
||||
|
||||
try {
|
||||
const publicKey = crypto.createPublicKey(keyPem);
|
||||
return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | Buffer} content
|
||||
* @returns {string}
|
||||
*/
|
||||
function sha256Hex(content) {
|
||||
return crypto.createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function extractSha256Value(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
if (isObject(value) && typeof value.sha256 === "string") {
|
||||
const normalized = value.sha256.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} manifestRaw
|
||||
* @returns {{ schemaVersion: string; algorithm: string; files: Record<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 timeout = globalThis.setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetchFn(feedUrl, {
|
||||
const response = await fetchFn(targetUrl, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json" },
|
||||
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
const payload = await response.json();
|
||||
if (!isValidFeedPayload(payload)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
// Re-throw security policy violations - these should never be silently caught
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
throw error;
|
||||
}
|
||||
// Network errors, timeouts, etc. return null (graceful degradation)
|
||||
return null;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedPath
|
||||
* @param {{
|
||||
* signaturePath?: string;
|
||||
* checksumsPath?: string;
|
||||
* checksumsSignaturePath?: string;
|
||||
* publicKeyPem?: string;
|
||||
* checksumsPublicKeyPem?: string;
|
||||
* allowUnsigned?: boolean;
|
||||
* verifyChecksumManifest?: boolean;
|
||||
* checksumFeedEntry?: string;
|
||||
* checksumSignatureEntry?: string;
|
||||
* checksumPublicKeyEntry?: string;
|
||||
* }} [options]
|
||||
* @returns {Promise<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 = {
|
||||
version: string;
|
||||
updated?: string;
|
||||
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 { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
|
||||
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
|
||||
import { parseAffectedSpecifier, isValidFeedPayload, loadRemoteFeed } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
||||
import {
|
||||
defaultChecksumsUrl,
|
||||
parseAffectedSpecifier,
|
||||
loadLocalFeed,
|
||||
loadRemoteFeed,
|
||||
} from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||
const DEFAULT_SUITE_DIR = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const DEFAULT_LOCAL_FEED = path.join(DEFAULT_SUITE_DIR, "advisories", "feed.json");
|
||||
const DEFAULT_LOCAL_FEED_SIG = `${DEFAULT_LOCAL_FEED}.sig`;
|
||||
const DEFAULT_LOCAL_FEED_CHECKSUMS = path.join(DEFAULT_SUITE_DIR, "advisories", "checksums.json");
|
||||
const DEFAULT_LOCAL_FEED_CHECKSUMS_SIG = `${DEFAULT_LOCAL_FEED_CHECKSUMS}.sig`;
|
||||
const DEFAULT_FEED_PUBLIC_KEY = path.join(DEFAULT_SUITE_DIR, "advisories", "feed-signing-public.pem");
|
||||
const EXIT_CONFIRM_REQUIRED = 42;
|
||||
|
||||
function printUsage() {
|
||||
@@ -87,12 +96,10 @@ function affectedSpecifierMatches(specifier, skillName, version) {
|
||||
return versionMatches(version, parsed.versionSpec);
|
||||
}
|
||||
|
||||
function affectedSpecifierMatchesNameOnly(specifier, skillName) {
|
||||
function affectedSpecifierMatchesWithoutVersion(specifier, skillName) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) return false;
|
||||
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false;
|
||||
const vs = parsed.versionSpec.trim();
|
||||
return !vs || vs === "*" || vs.toLowerCase() === "any";
|
||||
return normalizeSkillName(parsed.name) === normalizeSkillName(skillName);
|
||||
}
|
||||
|
||||
function advisoryLooksHighRisk(advisory) {
|
||||
@@ -108,17 +115,47 @@ function advisoryLooksHighRisk(advisory) {
|
||||
|
||||
async function loadFeed() {
|
||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||
const feedChecksumsSignatureUrl = process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||
const localFeedPath = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED;
|
||||
const localFeedSigPath = process.env.CLAWSEC_LOCAL_FEED_SIG || DEFAULT_LOCAL_FEED_SIG;
|
||||
const localFeedChecksumsPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || DEFAULT_LOCAL_FEED_CHECKSUMS;
|
||||
const localFeedChecksumsSigPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || DEFAULT_LOCAL_FEED_CHECKSUMS_SIG;
|
||||
const feedPublicKeyPath = process.env.CLAWSEC_FEED_PUBLIC_KEY || DEFAULT_FEED_PUBLIC_KEY;
|
||||
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl);
|
||||
if (allowUnsigned) {
|
||||
process.stderr.write(
|
||||
"WARNING: CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. This temporary migration compatibility bypass should be removed once signed feed artifacts are available.\n",
|
||||
);
|
||||
}
|
||||
|
||||
const publicKeyPem = allowUnsigned ? "" : await fs.readFile(feedPublicKeyPath, "utf8");
|
||||
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl, {
|
||||
signatureUrl: feedSignatureUrl,
|
||||
checksumsUrl: feedChecksumsUrl,
|
||||
checksumsSignatureUrl: feedChecksumsSignatureUrl,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
});
|
||||
if (remoteFeed) return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
||||
|
||||
const raw = await fs.readFile(localFeedPath, "utf8");
|
||||
const payload = JSON.parse(raw);
|
||||
if (!isValidFeedPayload(payload)) {
|
||||
throw new Error(`Invalid fallback advisory feed format: ${localFeedPath}`);
|
||||
}
|
||||
return { feed: payload, source: `local:${localFeedPath}` };
|
||||
const localFeed = await loadLocalFeed(localFeedPath, {
|
||||
signaturePath: localFeedSigPath,
|
||||
checksumsPath: localFeedChecksumsPath,
|
||||
checksumsSignaturePath: localFeedChecksumsSigPath,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
checksumPublicKeyEntry: path.basename(feedPublicKeyPath),
|
||||
});
|
||||
return { feed: localFeed, source: `local:${localFeedPath}` };
|
||||
}
|
||||
|
||||
function findMatches(feed, skillName, version) {
|
||||
@@ -133,7 +170,7 @@ function findMatches(feed, skillName, version) {
|
||||
affected.filter((specifier) =>
|
||||
version
|
||||
? affectedSpecifierMatches(specifier, skillName, version)
|
||||
: affectedSpecifierMatchesNameOnly(specifier, skillName),
|
||||
: affectedSpecifierMatchesWithoutVersion(specifier, skillName),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -186,6 +223,12 @@ async function main() {
|
||||
|
||||
process.stdout.write(`Advisory source: ${source}\n`);
|
||||
|
||||
if (!args.version) {
|
||||
process.stdout.write(
|
||||
"No --version provided. Conservatively matching any advisory for the requested skill name.\n",
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
printMatches(matches, args.skill, args.version);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clawsec-suite",
|
||||
"version": "0.0.9",
|
||||
"description": "ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
||||
"version": "0.0.10",
|
||||
"description": "ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
@@ -19,7 +19,9 @@
|
||||
"agents",
|
||||
"ai",
|
||||
"suite",
|
||||
"openclaw"
|
||||
"openclaw",
|
||||
"signature",
|
||||
"verification"
|
||||
],
|
||||
"sbom": {
|
||||
"files": [
|
||||
@@ -28,6 +30,11 @@
|
||||
"required": true,
|
||||
"description": "Suite skill documentation and installation guide"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and security improvements changelog"
|
||||
},
|
||||
{
|
||||
"path": "HEARTBEAT.md",
|
||||
"required": true,
|
||||
@@ -38,6 +45,26 @@
|
||||
"required": true,
|
||||
"description": "Embedded advisory feed seed (merged from clawsec-feed)"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed.json.sig",
|
||||
"required": true,
|
||||
"description": "Detached Ed25519 signature for advisory feed"
|
||||
},
|
||||
{
|
||||
"path": "advisories/checksums.json",
|
||||
"required": true,
|
||||
"description": "SHA-256 checksum manifest for advisory artifacts"
|
||||
},
|
||||
{
|
||||
"path": "advisories/checksums.json.sig",
|
||||
"required": true,
|
||||
"description": "Detached Ed25519 signature for checksum manifest"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed-signing-public.pem",
|
||||
"required": true,
|
||||
"description": "Pinned Ed25519 public key for feed signature verification"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/HOOK.md",
|
||||
"required": true,
|
||||
@@ -46,7 +73,7 @@
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/handler.ts",
|
||||
"required": true,
|
||||
"description": "OpenClaw hook handler for approval-gated advisory actions"
|
||||
"description": "OpenClaw hook handler for approval-gated advisory actions with signature verification"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/utils.mjs",
|
||||
@@ -61,7 +88,22 @@
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/feed.mjs",
|
||||
"required": true,
|
||||
"description": "Shared advisory feed loading and validation"
|
||||
"description": "Advisory feed loading with Ed25519 signature and checksum manifest verification"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/types.ts",
|
||||
"required": true,
|
||||
"description": "TypeScript type definitions for hook and feed structures"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/state.ts",
|
||||
"required": true,
|
||||
"description": "Advisory state persistence and loading"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/matching.ts",
|
||||
"required": true,
|
||||
"description": "Advisory-to-skill matching and alert message generation"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_advisory_hook.mjs",
|
||||
@@ -76,7 +118,22 @@
|
||||
{
|
||||
"path": "scripts/guarded_skill_install.mjs",
|
||||
"required": true,
|
||||
"description": "Two-step confirmation installer that blocks risky skill installs until explicit second approval"
|
||||
"description": "Two-step confirmation installer with signature verification that blocks risky skill installs"
|
||||
},
|
||||
{
|
||||
"path": "scripts/sign_detached_ed25519.mjs",
|
||||
"required": false,
|
||||
"description": "Utility script for generating Ed25519 detached signatures"
|
||||
},
|
||||
{
|
||||
"path": "scripts/verify_detached_ed25519.mjs",
|
||||
"required": false,
|
||||
"description": "Utility script for verifying Ed25519 detached signatures"
|
||||
},
|
||||
{
|
||||
"path": "scripts/generate_checksums_json.mjs",
|
||||
"required": false,
|
||||
"description": "Utility script for generating SHA-256 checksum manifests"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -85,14 +142,20 @@
|
||||
"source_skill": "clawsec-feed",
|
||||
"source_version": "0.0.4",
|
||||
"paths": [
|
||||
"advisories/feed.json"
|
||||
"advisories/feed.json",
|
||||
"advisories/feed.json.sig",
|
||||
"advisories/checksums.json",
|
||||
"advisories/checksums.json.sig",
|
||||
"advisories/feed-signing-public.pem"
|
||||
],
|
||||
"capabilities": [
|
||||
"advisory-feed monitoring",
|
||||
"new-advisory detection",
|
||||
"affected-skill cross-reference",
|
||||
"approval-gated malicious-skill removal recommendations",
|
||||
"double-confirmation gating for risky skill installs"
|
||||
"double-confirmation gating for risky skill installs",
|
||||
"Ed25519 signature verification",
|
||||
"checksum manifest verification"
|
||||
],
|
||||
"standalone_available": true,
|
||||
"deprecation_plan": "standalone skill may be retired after suite migration is verified"
|
||||
@@ -153,7 +216,8 @@
|
||||
"bins": [
|
||||
"curl",
|
||||
"jq",
|
||||
"shasum"
|
||||
"shasum",
|
||||
"openssl"
|
||||
]
|
||||
},
|
||||
"triggers": [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user