From 5ee8587b1ea149ee227390884d194fb596abb529 Mon Sep 17 00:00:00 2001 From: davida-ps Date: Thu, 12 Feb 2026 17:49:34 +0100 Subject: [PATCH] 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 --- .github/actions/sign-and-verify/action.yml | 110 ++++ .github/requirements-lint-python.txt | 2 + .github/workflows/ci.yml | 39 +- .github/workflows/community-advisory.yml | 69 ++- .github/workflows/deploy-pages.yml | 183 ++++-- .github/workflows/poll-nvd-cves.yml | 54 +- .github/workflows/skill-release.yml | 227 ++++++- MIGRATION-SIGNED-FEED.md | 167 +++++ README.md | 12 +- SECURITY-SIGNING.md | 215 +++++++ pages/Home.tsx | 18 - skills/clawsec-suite/CHANGELOG.md | 76 +++ skills/clawsec-suite/SKILL.md | 59 +- .../hooks/clawsec-advisory-guardian/HOOK.md | 9 + .../clawsec-advisory-guardian/handler.ts | 85 ++- .../clawsec-advisory-guardian/lib/feed.mjs | 477 ++++++++++++++- .../clawsec-advisory-guardian/lib/types.ts | 1 + .../scripts/generate_checksums_json.mjs | 85 +++ .../scripts/guarded_skill_install.mjs | 69 ++- .../scripts/sign_detached_ed25519.mjs | 65 ++ .../scripts/verify_detached_ed25519.mjs | 73 +++ skills/clawsec-suite/skill.json | 82 ++- .../test/feed_verification.test.mjs | 568 ++++++++++++++++++ .../test/guarded_install.test.mjs | 378 ++++++++++++ 24 files changed, 2970 insertions(+), 153 deletions(-) create mode 100644 .github/actions/sign-and-verify/action.yml create mode 100644 .github/requirements-lint-python.txt create mode 100644 MIGRATION-SIGNED-FEED.md create mode 100644 SECURITY-SIGNING.md create mode 100644 skills/clawsec-suite/CHANGELOG.md create mode 100644 skills/clawsec-suite/scripts/generate_checksums_json.mjs create mode 100644 skills/clawsec-suite/scripts/sign_detached_ed25519.mjs create mode 100644 skills/clawsec-suite/scripts/verify_detached_ed25519.mjs create mode 100644 skills/clawsec-suite/test/feed_verification.test.mjs create mode 100644 skills/clawsec-suite/test/guarded_install.test.mjs diff --git a/.github/actions/sign-and-verify/action.yml b/.github/actions/sign-and-verify/action.yml new file mode 100644 index 0000000..1ba3641 --- /dev/null +++ b/.github/actions/sign-and-verify/action.yml @@ -0,0 +1,110 @@ +name: Sign and Verify File +description: Sign a file with the CI private key and verify the signature against one or more files. + +inputs: + private_key: + description: PEM-encoded private key contents. + required: true + private_key_passphrase: + description: Optional passphrase for encrypted private keys. + required: false + default: "" + input_file: + description: File to sign. + required: true + signature_file: + description: Output path for base64 signature. + required: true + verify_files: + description: Newline-separated list of files to verify with the generated signature. Defaults to input_file. + required: false + default: "" + public_key_output: + description: Optional output path for the derived public key. + required: false + default: "" + +outputs: + signature_file: + description: Signature file path. + value: ${{ steps.sign.outputs.signature_file }} + public_key_file: + description: Public key file path when public_key_output is set. + value: ${{ steps.sign.outputs.public_key_file }} + +runs: + using: composite + steps: + - id: sign + shell: bash + env: + PRIVATE_KEY: ${{ inputs.private_key }} + PRIVATE_KEY_PASSPHRASE: ${{ inputs.private_key_passphrase }} + INPUT_FILE: ${{ inputs.input_file }} + SIGNATURE_FILE: ${{ inputs.signature_file }} + VERIFY_FILES_RAW: ${{ inputs.verify_files }} + PUBLIC_KEY_OUTPUT: ${{ inputs.public_key_output }} + run: | + set -euo pipefail + + if [ -z "${PRIVATE_KEY:-}" ]; then + echo "::error::Missing required private key input." + exit 1 + fi + + if [ ! -f "${INPUT_FILE}" ]; then + echo "::error file=${INPUT_FILE}::Input file not found for signing." + exit 1 + fi + + umask 077 + tmp_dir="$(mktemp -d)" + cleanup() { + rm -rf "${tmp_dir}" + } + trap cleanup EXIT + + key_file="${tmp_dir}/private.pem" + pub_file="${tmp_dir}/public.pem" + sig_bin="${tmp_dir}/signature.bin" + + printf '%s' "${PRIVATE_KEY}" > "${key_file}" + + passin_args=() + if [ -n "${PRIVATE_KEY_PASSPHRASE:-}" ]; then + passin_args=(-passin "pass:${PRIVATE_KEY_PASSPHRASE}") + fi + + openssl pkey -in "${key_file}" "${passin_args[@]}" -pubout -out "${pub_file}" + + mkdir -p "$(dirname "${SIGNATURE_FILE}")" + # Sign with Ed25519 (requires -rawin flag for raw input) + openssl pkeyutl -sign -rawin -inkey "${key_file}" "${passin_args[@]}" -in "${INPUT_FILE}" \ + | openssl base64 -A > "${SIGNATURE_FILE}" + + openssl base64 -d -A -in "${SIGNATURE_FILE}" -out "${sig_bin}" + + verify_files="${VERIFY_FILES_RAW}" + if [ -z "${verify_files}" ]; then + verify_files="${INPUT_FILE}" + fi + + while IFS= read -r verify_file; do + [ -z "${verify_file}" ] && continue + if [ ! -f "${verify_file}" ]; then + echo "::error file=${verify_file}::Verification target does not exist." + exit 1 + fi + # Verify Ed25519 signature (requires -rawin flag for raw input) + openssl pkeyutl -verify -rawin -pubin -inkey "${pub_file}" -sigfile "${sig_bin}" -in "${verify_file}" >/dev/null + done <<< "${verify_files}" + + if [ -n "${PUBLIC_KEY_OUTPUT}" ]; then + mkdir -p "$(dirname "${PUBLIC_KEY_OUTPUT}")" + cp "${pub_file}" "${PUBLIC_KEY_OUTPUT}" + echo "public_key_file=${PUBLIC_KEY_OUTPUT}" >> "${GITHUB_OUTPUT}" + else + echo "public_key_file=" >> "${GITHUB_OUTPUT}" + fi + + echo "signature_file=${SIGNATURE_FILE}" >> "${GITHUB_OUTPUT}" diff --git a/.github/requirements-lint-python.txt b/.github/requirements-lint-python.txt new file mode 100644 index 0000000..9c85f4b --- /dev/null +++ b/.github/requirements-lint-python.txt @@ -0,0 +1,2 @@ +ruff==0.6.9 +bandit==1.7.9 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d91fa8a..4378c33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,8 @@ jobs: name: Lint TypeScript/React runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '20' cache: 'npm' @@ -28,12 +28,14 @@ jobs: name: Lint Python runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' + cache: 'pip' + cache-dependency-path: '.github/requirements-lint-python.txt' - name: Install linters - run: pip install ruff bandit + run: python -m pip install -r .github/requirements-lint-python.txt - name: Ruff (lint + format check) run: ruff check utils/ --output-format=github - name: Bandit (security) @@ -43,9 +45,9 @@ jobs: name: Lint Shell Scripts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: ShellCheck - uses: ludeeus/action-shellcheck@master + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 with: scandir: './scripts' severity: warning @@ -54,9 +56,9 @@ jobs: name: Security Scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Trivy FS Scan - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 with: scan-type: 'fs' scan-ref: '.' @@ -64,7 +66,7 @@ jobs: exit-code: '1' ignore-unfixed: true - name: Trivy Config Scan - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 with: scan-type: 'config' scan-ref: '.' @@ -75,8 +77,8 @@ jobs: name: Dependency Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '20' cache: 'npm' @@ -85,3 +87,16 @@ jobs: run: npm audit --audit-level=high --registry=https://registry.npmjs.org - name: Check for outdated deps run: npm outdated || true + + clawsec-suite-tests: + name: ClawSec Suite Verification Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '20' + - name: Feed Verification Tests + run: node skills/clawsec-suite/test/feed_verification.test.mjs + - name: Guarded Install Tests + run: node skills/clawsec-suite/test/guarded_install.test.mjs diff --git a/.github/workflows/community-advisory.yml b/.github/workflows/community-advisory.yml index b7a67a0..3ef543f 100644 --- a/.github/workflows/community-advisory.yml +++ b/.github/workflows/community-advisory.yml @@ -7,6 +7,7 @@ on: permissions: contents: write issues: write + pull-requests: write concurrency: group: community-advisory @@ -14,7 +15,9 @@ concurrency: env: FEED_PATH: advisories/feed.json + FEED_SIG_PATH: advisories/feed.json.sig SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json + SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig jobs: process-advisory: @@ -22,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 @@ -196,46 +199,80 @@ jobs: exit 1 fi - - name: Commit changes + - name: Sign advisory feed and verify if: steps.parse.outputs.already_exists != 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + uses: ./.github/actions/sign-and-verify + with: + private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }} + private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }} + input_file: ${{ env.FEED_PATH }} + signature_file: ${{ env.FEED_SIG_PATH }} + verify_files: | + ${{ env.FEED_PATH }} + ${{ env.SKILL_FEED_PATH }} - git add "$FEED_PATH" "$SKILL_FEED_PATH" + - name: Sync advisory signature to skill feed + if: steps.parse.outputs.already_exists != 'true' + run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH" - ADVISORY_ID="${{ steps.parse.outputs.advisory_id }}" - git commit -m "chore: add community advisory $ADVISORY_ID + - name: Create Pull Request + if: steps.parse.outputs.already_exists != 'true' + id: create-pr + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: automated/community-advisory-${{ github.event.issue.number }} + delete-branch: true + title: "chore: add community advisory ${{ steps.parse.outputs.advisory_id }}" + body: | + ## Summary + Add community advisory `${{ steps.parse.outputs.advisory_id }}` from issue #${{ github.event.issue.number }}. - Added from issue #${{ github.event.issue.number }} - Issue: ${{ github.event.issue.html_url }}" + - Issue: ${{ github.event.issue.html_url }} + - Reporter: @${{ github.event.issue.user.login }} + - Trigger: `advisory-approved` label - git push + --- + *This PR was generated by the community advisory workflow.* + commit-message: | + chore: add community advisory ${{ steps.parse.outputs.advisory_id }} + + Added from issue #${{ github.event.issue.number }} + Issue: ${{ github.event.issue.html_url }} + add-paths: | + ${{ env.FEED_PATH }} + ${{ env.FEED_SIG_PATH }} + ${{ env.SKILL_FEED_PATH }} + ${{ env.SKILL_FEED_SIG_PATH }} - name: Comment on issue if: steps.parse.outputs.already_exists != 'true' - uses: actions/github-script@v7 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const advisoryId = '${{ steps.parse.outputs.advisory_id }}'; + const pullRequestUrl = '${{ steps.create-pr.outputs.pull-request-url }}'; + const operation = '${{ steps.create-pr.outputs.pull-request-operation }}'; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - body: `## Advisory Published + body: `## Advisory Pull Request Opened - This security report has been published to the ClawSec advisory feed. + This security report has been prepared for publication in the ClawSec advisory feed. **Advisory ID:** \`${advisoryId}\` + **Pull Request:** ${pullRequestUrl || 'No PR generated (no file changes detected)'} + **PR Operation:** \`${operation || 'none'}\` - The advisory is now available in the feed and will be picked up by agents on their next feed check. + The advisory will be published after the pull request is merged. Thank you for your contribution to community security!` }); - name: Comment if already exists if: steps.parse.outputs.already_exists == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const advisoryId = '${{ steps.parse.outputs.advisory_id }}'; diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 4aaf3ee..1374b5d 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -23,10 +23,11 @@ jobs: if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Auto-discover skills from releases env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | @@ -48,17 +49,17 @@ jobs: } export -f download_asset # Export for use in subshells (while loop) - # Fetch all releases - RELEASES=$(curl -sSL \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + # Fetch all releases (paginated) + RELEASES=$(gh api --paginate \ -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${REPO}/releases?per_page=100") + "/repos/${REPO}/releases?per_page=100" \ + | jq -s 'add // []') # Start building skills index echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json FIRST_SKILL=true - PROCESSED_SKILLS="" + declare -A PROCESSED_SKILLS=() # Process each release (using process substitution to avoid subshell) while read -r release; do @@ -70,7 +71,7 @@ jobs: VERSION="${BASH_REMATCH[2]}" # Skip if we already processed a newer version of this skill - if echo "$PROCESSED_SKILLS" | grep -q "^${SKILL_NAME}$"; then + if [[ -n "${PROCESSED_SKILLS[$SKILL_NAME]+x}" ]]; then echo "Skipping older version: $TAG (already have newer)" continue fi @@ -99,13 +100,16 @@ jobs: continue fi - # Mirror all release assets under a GitHub-compatible path so users can - # swap the host (github.com → clawsec.prompt.security) if GitHub is blocked. - MIRROR_DIR="public/releases/download/${TAG}" - mkdir -p "$MIRROR_DIR" - mv "$SKILL_JSON_TMP" "$MIRROR_DIR/skill.json" + # Security: Download to temp directory first, verify signatures, then mirror to final location. + # This ensures unverified releases never appear in public/releases or the skills catalog. - # Download all remaining assets for this release (retain asset names) + # Use temp directory for downloads before verification + TEMP_DOWNLOAD_DIR=$(mktemp -d) + + # Move skill.json to temp dir first + mv "$SKILL_JSON_TMP" "$TEMP_DOWNLOAD_DIR/skill.json" + + # Download all remaining assets to temp dir while read -r asset; do ASSET_ID=$(echo "$asset" | jq -r '.id') ASSET_NAME=$(echo "$asset" | jq -r '.name') @@ -121,16 +125,41 @@ jobs: continue fi - download_asset "$ASSET_ID" "$MIRROR_DIR/$ASSET_NAME" - echo " Mirrored: $ASSET_NAME" + download_asset "$ASSET_ID" "$TEMP_DOWNLOAD_DIR/$ASSET_NAME" + echo " Downloaded to temp: $ASSET_NAME" done < <(echo "$release" | jq -c '.assets[]') + # Verify signed checksums when signature artifacts are present. + # Legacy releases without signatures are still mirrored for backward compatibility. + if [ -f "$TEMP_DOWNLOAD_DIR/checksums.sig" ] && [ -f "$TEMP_DOWNLOAD_DIR/signing-public.pem" ] && [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then + openssl base64 -d -A -in "$TEMP_DOWNLOAD_DIR/checksums.sig" -out "$TEMP_DOWNLOAD_DIR/checksums.sig.bin" + # Verify Ed25519 signature (requires -rawin) + if ! openssl pkeyutl -verify -rawin -pubin -inkey "$TEMP_DOWNLOAD_DIR/signing-public.pem" -sigfile "$TEMP_DOWNLOAD_DIR/checksums.sig.bin" -in "$TEMP_DOWNLOAD_DIR/checksums.json"; then + echo " Warning: Invalid checksums signature for $TAG; skipping skill" + rm -rf "$TEMP_DOWNLOAD_DIR" + continue + fi + rm -f "$TEMP_DOWNLOAD_DIR/checksums.sig.bin" + echo " Verified checksums signature" + elif [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then + echo " Warning: Unsigned legacy checksums for $TAG (missing checksums.sig/signing-public.pem)" + fi + + # Verification passed or skipped (legacy) - mirror to final location + MIRROR_DIR="public/releases/download/${TAG}" + mkdir -p "$MIRROR_DIR" + cp -r "$TEMP_DOWNLOAD_DIR"/* "$MIRROR_DIR"/ + echo " Mirrored to: $MIRROR_DIR" + + # Clean up temp directory + rm -rf "$TEMP_DOWNLOAD_DIR" + # Copy the subset needed for the site catalog (skill pages) mkdir -p "public/skills/${SKILL_NAME}" cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json" echo " Added to catalog: skill.json" - for file in checksums.json README.md SKILL.md; do + for file in checksums.json checksums.sig signing-public.pem README.md SKILL.md; do if [ -f "$MIRROR_DIR/$file" ]; then cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file" echo " Added to catalog: $file" @@ -158,7 +187,7 @@ jobs: echo "$SKILL_DATA" >> public/skills/index.json # Mark this skill as processed (track newest only) - PROCESSED_SKILLS="${PROCESSED_SKILLS}${SKILL_NAME}\n" + PROCESSED_SKILLS["$SKILL_NAME"]=1 else echo " Warning: skill.json not found in release assets" fi @@ -179,35 +208,105 @@ jobs: echo "=== Skills Directory ===" ls -la public/skills/ - - name: Create root checksums placeholder - run: | - # Create empty checksums.json placeholder for root level - echo '{"version":"1.0.0","files":{}}' > public/checksums.json - echo "Created checksums.json placeholder" - - name: Copy advisory feed to public run: | + set -euo pipefail mkdir -p public/advisories cp advisories/feed.json public/advisories/feed.json echo "Copied advisory feed to public/advisories/" cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories" + - name: Generate advisory checksums manifest + run: | + set -euo pipefail + + FEED_FILE="public/advisories/feed.json" + FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}') + FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE") + + # Generate checksums manifest conforming to parseChecksumsManifest expectations: + # - schema_version: "1" (manifest format version) + # - algorithm: "sha256" (hash algorithm) + # - version: "1.1.0" (feed content version, for informational purposes) + # - generated_at, repository: metadata + # - files: map of path -> {sha256, size, path, url} + jq -n \ + --arg schema_version "1" \ + --arg algorithm "sha256" \ + --arg version "1.1.0" \ + --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg repo "${{ github.repository }}" \ + --arg sha "$FEED_SHA" \ + --argjson size "$FEED_SIZE" \ + '{ + schema_version: $schema_version, + algorithm: $algorithm, + version: $version, + generated_at: $generated, + repository: $repo, + files: { + "advisories/feed.json": { + sha256: $sha, + size: $size, + path: "advisories/feed.json", + url: "https://clawsec.prompt.security/advisories/feed.json" + } + } + }' > public/checksums.json + + echo "Generated public/checksums.json" + jq . public/checksums.json + + - name: Sign advisory feed and verify + uses: ./.github/actions/sign-and-verify + with: + private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }} + private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }} + input_file: public/advisories/feed.json + signature_file: public/advisories/feed.json.sig + public_key_output: public/signing-public.pem + + - name: Sign checksums and verify + uses: ./.github/actions/sign-and-verify + with: + private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }} + private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }} + input_file: public/checksums.json + signature_file: public/checksums.sig + + - name: Copy public key to advisory directory + run: | + # Clients expect the public key at advisories/feed-signing-public.pem + mkdir -p public/advisories + cp public/signing-public.pem public/advisories/feed-signing-public.pem + echo "Public key available at:" + echo " - public/signing-public.pem (root)" + echo " - public/advisories/feed-signing-public.pem (advisory-specific)" + + - name: Show signed advisory artifacts + run: | + echo "Signed advisory artifacts:" + ls -la public/advisories/feed.json* + ls -la public/checksums.json public/checksums.sig public/signing-public.pem + - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '20' cache: 'npm' - name: Get latest clawsec-suite release URL env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | - LATEST_TAG=$(curl -sSL \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${REPO}/releases?per_page=100" | \ - jq -r '[.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty') + LATEST_TAG=$( + gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + "/repos/${REPO}/releases?per_page=100" \ + | jq -r -s 'add // [] | [.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty' + ) if [ -n "$LATEST_TAG" ]; then echo "Found latest clawsec-suite tag: $LATEST_TAG" @@ -229,12 +328,26 @@ jobs: echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)" fi - # Mirror advisories feed at the path referenced by suite docs/heartbeat + # Mirror advisories feed + signatures at the path referenced by suite docs/heartbeat if [ -f "public/advisories/feed.json" ]; then mkdir -p "$MIRROR_LATEST_DIR/advisories" cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/advisories/feed.json" cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json" fi + if [ -f "public/advisories/feed.json.sig" ]; then + mkdir -p "$MIRROR_LATEST_DIR/advisories" + cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/advisories/feed.json.sig" + cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/feed.json.sig" + fi + if [ -f "public/checksums.json" ]; then + cp "public/checksums.json" "$MIRROR_LATEST_DIR/checksums.json" + fi + if [ -f "public/checksums.sig" ]; then + cp "public/checksums.sig" "$MIRROR_LATEST_DIR/checksums.sig" + fi + if [ -f "public/signing-public.pem" ]; then + cp "public/signing-public.pem" "$MIRROR_LATEST_DIR/signing-public.pem" + fi else echo "No clawsec-suite release found, using fallback" fi @@ -251,7 +364,9 @@ jobs: - name: Copy skills data to dist run: | cp -r public/skills dist/skills 2>/dev/null || echo "No skills directory" - cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No legacy checksums" + cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No checksums manifest" + cp public/checksums.sig dist/checksums.sig 2>/dev/null || echo "No checksums signature" + cp public/signing-public.pem dist/signing-public.pem 2>/dev/null || echo "No signing public key" cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory" echo "=== Dist contents ===" @@ -263,10 +378,10 @@ jobs: run: touch dist/.nojekyll - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: path: ./dist @@ -280,4 +395,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/poll-nvd-cves.yml b/.github/workflows/poll-nvd-cves.yml index 3d50019..3bee19f 100644 --- a/.github/workflows/poll-nvd-cves.yml +++ b/.github/workflows/poll-nvd-cves.yml @@ -22,7 +22,9 @@ concurrency: env: FEED_PATH: advisories/feed.json + FEED_SIG_PATH: advisories/feed.json.sig SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json + SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig KEYWORDS: "OpenClaw clawdbot Moltbot" GITHUB_REF_PATTERN: "github.com/openclaw/openclaw" @@ -31,7 +33,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 @@ -80,6 +82,7 @@ jobs: - name: Fetch CVEs from NVD id: fetch run: | + set -euo pipefail mkdir -p tmp START_DATE="${{ steps.dates.outputs.start_date }}" @@ -90,6 +93,8 @@ jobs: END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g') echo "=== Fetching CVEs from NVD ===" + + FAILED_KEYWORDS=() # Fetch for each keyword for KEYWORD in $KEYWORDS; do @@ -99,11 +104,22 @@ jobs: echo "URL: $URL" # Fetch with retry logic + keyword_ok=false + last_http_code="" for i in 1 2 3; do - HTTP_CODE=$(curl -s -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL") + HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL" || true) + if [ -z "$HTTP_CODE" ]; then + HTTP_CODE="000" + fi + last_http_code="$HTTP_CODE" if [ "$HTTP_CODE" = "200" ]; then - echo "Success for $KEYWORD" - break + if jq -e . "tmp/nvd_${KEYWORD}.json" >/dev/null 2>&1; then + echo "Success for $KEYWORD" + keyword_ok=true + break + fi + echo "Invalid JSON for $KEYWORD, retry $i..." + sleep 5 elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then echo "Rate limited, waiting 30s before retry $i..." sleep 30 @@ -112,11 +128,21 @@ jobs: sleep 5 fi done + + if [ "$keyword_ok" != "true" ]; then + echo "::error::Failed to fetch valid NVD response for keyword '$KEYWORD' (last HTTP code: ${last_http_code:-unknown})." + FAILED_KEYWORDS+=("$KEYWORD") + fi # NVD recommends 6 second delay between requests sleep 6 done + if [ "${#FAILED_KEYWORDS[@]}" -gt 0 ]; then + echo "::error::NVD fetch failed for keyword(s): ${FAILED_KEYWORDS[*]}" + exit 1 + fi + echo "=== Fetch complete ===" ls -la tmp/ @@ -508,6 +534,22 @@ jobs: exit 1 fi + - name: Sign advisory feed and verify + if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0' + uses: ./.github/actions/sign-and-verify + with: + private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }} + private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }} + input_file: ${{ env.FEED_PATH }} + signature_file: ${{ env.FEED_SIG_PATH }} + verify_files: | + ${{ env.FEED_PATH }} + ${{ env.SKILL_FEED_PATH }} + + - name: Sync advisory signature to skill feed + if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0' + run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH" + - name: Clean workspace for PR if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0' run: | @@ -518,7 +560,7 @@ jobs: - name: Create Pull Request if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0' id: create-pr - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ secrets.GITHUB_TOKEN }} branch: automated/nvd-cve-update-${{ github.run_id }} @@ -543,7 +585,9 @@ jobs: Poll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }} add-paths: | ${{ env.FEED_PATH }} + ${{ env.FEED_SIG_PATH }} ${{ env.SKILL_FEED_PATH }} + ${{ env.SKILL_FEED_SIG_PATH }} - name: Summary run: | diff --git a/.github/workflows/skill-release.yml b/.github/workflows/skill-release.yml index 631c8f5..27f18dd 100644 --- a/.github/workflows/skill-release.yml +++ b/.github/workflows/skill-release.yml @@ -26,7 +26,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -174,10 +174,21 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + - name: Generate test signing key for dry-run + run: | + set -euo pipefail + echo "Generating temporary Ed25519 test key for dry-run validation" + umask 077 + mkdir -p /tmp/test-signing + # Use Ed25519 to match production signing (not RSA) + openssl genpkey -algorithm ED25519 -out /tmp/test-signing/private.pem + openssl pkey -in /tmp/test-signing/private.pem -pubout -out /tmp/test-signing/public.pem + echo "TEST_SIGNING_KEY_DIR=/tmp/test-signing" >> $GITHUB_ENV + - name: Run release dry-run for changed skills env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -185,6 +196,83 @@ jobs: run: | set -euo pipefail + # Helper function to sign advisory artifacts with test key (dry-run only) + sign_advisory_artifacts() { + local skill_dir="$1" + local advisory_dir="${skill_dir}/advisories" + + if [ ! -d "$advisory_dir" ] || [ ! -f "$advisory_dir/feed.json" ]; then + return 0 + fi + + echo " [Dry-run] Signing advisory artifacts with test key" + + local key_file="$TEST_SIGNING_KEY_DIR/private.pem" + local pub_file="$TEST_SIGNING_KEY_DIR/public.pem" + local tmp_sig_bin + + # Sign feed.json with Ed25519 (requires -rawin flag) + openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/feed.json" | \ + openssl base64 -A > "$advisory_dir/feed.json.sig" + + # Verify Ed25519 feed.json signature (requires -rawin flag) + tmp_sig_bin=$(mktemp) + openssl base64 -d -A -in "$advisory_dir/feed.json.sig" -out "$tmp_sig_bin" + if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/feed.json" >/dev/null 2>&1; then + echo "::error file=${skill_dir}/advisories/feed.json.sig::Feed signature verification failed after signing" + rm -f "$tmp_sig_bin" + return 1 + fi + rm -f "$tmp_sig_bin" + echo " [Dry-run] Verified feed.json signature" + + # Generate checksums.json + local feed_sha=$(sha256sum "$advisory_dir/feed.json" | awk '{print $1}') + local feed_size=$(stat -c%s "$advisory_dir/feed.json" 2>/dev/null || stat -f%z "$advisory_dir/feed.json") + local feed_sig_sha=$(sha256sum "$advisory_dir/feed.json.sig" | awk '{print $1}') + local feed_sig_size=$(stat -c%s "$advisory_dir/feed.json.sig" 2>/dev/null || stat -f%z "$advisory_dir/feed.json.sig") + + jq -n \ + --arg schema_version "1" \ + --arg algorithm "sha256" \ + --arg version "test" \ + --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg feed_sha "$feed_sha" \ + --argjson feed_size "$feed_size" \ + --arg feed_sig_sha "$feed_sig_sha" \ + --argjson feed_sig_size "$feed_sig_size" \ + '{ + schema_version: $schema_version, + algorithm: $algorithm, + version: $version, + generated_at: $generated, + files: { + "advisories/feed.json": {sha256: $feed_sha, size: $feed_size}, + "advisories/feed.json.sig": {sha256: $feed_sig_sha, size: $feed_sig_size} + } + }' > "$advisory_dir/checksums.json" + + # Sign checksums.json with Ed25519 (requires -rawin flag) + openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/checksums.json" | \ + openssl base64 -A > "$advisory_dir/checksums.json.sig" + + # Verify Ed25519 checksums.json signature (requires -rawin flag) + tmp_sig_bin=$(mktemp) + openssl base64 -d -A -in "$advisory_dir/checksums.json.sig" -out "$tmp_sig_bin" + if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/checksums.json" >/dev/null 2>&1; then + echo "::error file=${skill_dir}/advisories/checksums.json.sig::Checksums signature verification failed after signing" + rm -f "$tmp_sig_bin" + return 1 + fi + rm -f "$tmp_sig_bin" + echo " [Dry-run] Verified checksums.json signature" + + # Copy public key + cp "$pub_file" "$advisory_dir/feed-signing-public.pem" + + echo " [Dry-run] Advisory artifacts signed and verified with test key" + } + get_md_version() { local md_file="$1" awk ' @@ -263,6 +351,13 @@ jobs: out_assets="${out_root}/release-assets" mkdir -p "${out_assets}" + # --- Sign advisory artifacts if present (dry-run with test key) --- + if ! sign_advisory_artifacts "${skill_dir}"; then + failures=$((failures + 1)) + echo "::endgroup::" + continue + fi + # --- Stage SBOM files preserving directory structure --- staging_dir="$(mktemp -d)" inner_dir="${staging_dir}/${skill_name}" @@ -284,10 +379,28 @@ jobs: cp "${json_path}" "${inner_dir}/skill.json" + # --- Remove test-only artifacts from staging (don't include in release zip) --- + # The test signatures/keys were needed for SBOM validation but shouldn't ship + if [ -d "${inner_dir}/advisories" ]; then + rm -f "${inner_dir}/advisories/feed.json.sig" + rm -f "${inner_dir}/advisories/checksums.json" + rm -f "${inner_dir}/advisories/checksums.json.sig" + rm -f "${inner_dir}/advisories/feed-signing-public.pem" + echo " [Dry-run] Removed test signatures from release staging" + fi + # --- Create zip preserving directory structure --- zip_name="${skill_name}-v${version}.zip" (cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .) + # --- Clean up test artifacts from source directory --- + if [ -d "${skill_dir}/advisories" ]; then + rm -f "${skill_dir}/advisories/feed.json.sig" + rm -f "${skill_dir}/advisories/checksums.json" + rm -f "${skill_dir}/advisories/checksums.json.sig" + rm -f "${skill_dir}/advisories/feed-signing-public.pem" + fi + # --- Generate checksums.json via jq --- files_json="{}" while IFS= read -r file; do @@ -402,7 +515,7 @@ jobs: echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}" - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Validate skill exists run: | @@ -472,10 +585,79 @@ jobs: echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 20 + - name: Sign embedded advisory feed and verify + if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != '' + uses: ./.github/actions/sign-and-verify + with: + private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }} + private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }} + input_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json + signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json.sig + public_key_output: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed-signing-public.pem + + - name: Generate embedded advisory checksums manifest + if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != '' + run: | + set -euo pipefail + ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories" + FEED_SHA=$(sha256sum "$ADVISORY_DIR/feed.json" | awk '{print $1}') + FEED_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json") + FEED_SIG_SHA=$(sha256sum "$ADVISORY_DIR/feed.json.sig" | awk '{print $1}') + FEED_SIG_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json.sig" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json.sig") + + jq -n \ + --arg schema_version "1" \ + --arg algorithm "sha256" \ + --arg version "${{ steps.parse.outputs.version }}" \ + --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg repo "${{ github.repository }}" \ + --arg feed_sha "$FEED_SHA" \ + --argjson feed_size "$FEED_SIZE" \ + --arg feed_sig_sha "$FEED_SIG_SHA" \ + --argjson feed_sig_size "$FEED_SIG_SIZE" \ + '{ + schema_version: $schema_version, + algorithm: $algorithm, + version: $version, + generated_at: $generated, + repository: $repo, + files: { + "advisories/feed.json": { + sha256: $feed_sha, + size: $feed_size, + path: "advisories/feed.json" + }, + "advisories/feed.json.sig": { + sha256: $feed_sig_sha, + size: $feed_sig_size, + path: "advisories/feed.json.sig" + } + } + }' > "$ADVISORY_DIR/checksums.json" + + echo "Generated $ADVISORY_DIR/checksums.json" + jq . "$ADVISORY_DIR/checksums.json" + + - name: Sign embedded advisory checksums and verify + if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != '' + uses: ./.github/actions/sign-and-verify + with: + private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }} + private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }} + input_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json + signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json.sig + + - name: Show embedded advisory signing outputs + if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != '' + run: | + ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories" + echo "Successfully signed embedded advisory artifacts:" + ls -la "$ADVISORY_DIR" + - name: Install clawhub CLI if: steps.publishable.outputs.publishable == 'true' run: npm install -g clawhub @@ -597,6 +779,20 @@ jobs: echo "=== Release assets ===" ls -la release-assets/ + - name: Sign checksums and verify + uses: ./.github/actions/sign-and-verify + with: + private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }} + private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }} + input_file: release-assets/checksums.json + signature_file: release-assets/checksums.sig + public_key_output: release-assets/signing-public.pem + + - name: Show signed release assets + run: | + echo "Signed and verified release-assets/checksums.json" + ls -la release-assets/ + - name: Publish to ClawHub if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != '' run: | @@ -620,7 +816,7 @@ jobs: --no-input - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}" tag_name: ${{ github.ref_name }} @@ -637,22 +833,33 @@ jobs: **Manual download with verification:** ```bash - # 1. Download the release archive and checksums + # 1. Download the release archive, checksums, and signing material curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json + curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig + curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem - # 2. Verify archive checksum + # 2. Verify the checksums manifest signature (Ed25519) + openssl base64 -d -A -in checksums.sig -out checksums.sig.bin + openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json + + # 3. Verify archive checksum from the signed manifest echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c - # 3. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory) + # 4. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory) unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip ``` ### Verification - All files include SHA256 checksums in `checksums.json`: + `checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key. + Verify the signature first, then trust hashes from `checksums.json`: ```bash - curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json | jq . + curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json + curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig + curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem + openssl base64 -d -A -in checksums.sig -out checksums.sig.bin + openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json ``` ### Files diff --git a/MIGRATION-SIGNED-FEED.md b/MIGRATION-SIGNED-FEED.md new file mode 100644 index 0000000..feeb108 --- /dev/null +++ b/MIGRATION-SIGNED-FEED.md @@ -0,0 +1,167 @@ +# Migration Plan: Unsigned Feed → Signed Feed + +## 1) Objective + +Move ClawSec advisory distribution from unsigned `feed.json` delivery to detached-signature verification with minimal disruption. + +This plan is written against the current repository behavior: +- feed is produced by `poll-nvd-cves.yml` and `community-advisory.yml` +- feed is published by `deploy-pages.yml` +- suite consumers currently load unsigned JSON from remote/local fallback paths + +## 2) Baseline (today) + +Current feed paths in active use: +- Source of truth: `advisories/feed.json` +- Skill copy: `skills/clawsec-feed/advisories/feed.json` +- Pages copy: `public/advisories/feed.json` +- Latest mirror copy: `public/releases/latest/download/advisories/feed.json` + +Current consumer defaults: +- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts` +- `skills/clawsec-suite/scripts/guarded_skill_install.mjs` +- default URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json` + +## 3) Migration principles + +- **Dual-publish first**: publish signatures before enforcing verification. +- **Fail-open only during transition**: temporary compatibility period is explicit and time-bounded. +- **Measured rollout**: enforce verification after telemetry confirms stable signed publishing. +- **Fast rollback**: preserve a path back to unsigned behavior while root cause is investigated. + +## 4) Phased timeline + +### Phase 0 — Preparation (Week 0) + +Deliverables: +- signing keys generated and fingerprints recorded +- GitHub secrets created +- public key(s) added in repo +- runbooks approved (`SECURITY-SIGNING.md`, this file) + +Exit criteria: +- key fingerprints verified by reviewer +- protected branch/workflow controls enabled + +### Phase 1 — CI signing enabled, no client enforcement (Week 1) + +Implement: +- add feed signing step/workflow to produce `advisories/feed.json.sig` +- optionally produce `advisories/checksums.json` + `.sig` +- ensure CI verifies signatures before publishing artifacts + +Also update deployment: +- copy `.sig` artifacts to `public/advisories/` +- mirror `.sig` in `public/releases/latest/download/advisories/` + +Exit criteria: +- signatures generated successfully for all feed update paths +- deploy artifacts contain both payload and signature companions + +### Phase 2 — Consumer dual-read/dual-verify support (Week 2) + +Implement in consumers: +- read `feed.json` and `feed.json.sig` +- verify with pinned public key +- keep controlled temporary unsigned fallback during migration window + +Validation: +- test remote signed path +- test local signed fallback path +- test invalid signature rejection + +Exit criteria: +- verification logic released and tested +- no false-positive verification failures in soak period + +### Phase 3 — Enforcement (Week 3) + +Actions: +- disable temporary unsigned fallback behavior in default paths +- add CI/publish gates that fail when `.sig` is missing +- announce enforcement date in release notes and docs + +Exit criteria: +- all production clients verify signatures by default +- no unsigned feed dependency in standard installation flow + +### Phase 4 — Stabilization (Week 4) + +Actions: +- run first key rotation tabletop drill +- run rollback tabletop drill +- close migration with post-implementation review + +## 5) Rollback plan + +### Rollback triggers + +Initiate rollback if any of the following occur: +- sustained signature verification failures across clients +- signing workflow cannot produce valid signatures +- key compromise suspected but replacement key is not yet deployed +- deployment path publishes mismatched payload/signature pairs + +### Rollback levels + +### Level 1 (preferred): Verification bypass window, keep signed publishing + +Use when: signing is healthy, client-side verifier has a defect. + +Actions: +1. Re-enable temporary unsigned-acceptance behavior in client release branch. +2. Ship patch release with explicit expiry date for bypass. +3. Keep signing pipeline active to avoid authenticity gap. + +Recovery target: restore strict verification within 24–48h. + +### Level 2: Signed pipeline paused, unsigned feed temporarily authoritative + +Use when: signing pipeline is unstable or producing inconsistent artifacts. + +Actions: +1. Disable signing workflow or signing step. +2. Continue publishing unsigned `advisories/feed.json` via existing workflows. +3. Revert deploy gates that require `.sig` artifacts. +4. Open incident record and track time in unsigned mode. + +Recovery target: restore signed publishing ASAP, ideally <72h. + +### Level 3: Full release freeze + +Use when: compromise or integrity of repository/workflows is in doubt. + +Actions: +1. Pause feed mutation and deployment workflows. +2. Restore known-good commit for advisory files/workflows. +3. Rotate keys and credentials. +4. Resume pipeline only after security review sign-off. + +### Roll-forward after rollback + +- identify root cause +- add regression tests/gates +- redeploy signed artifacts +- publish incident + remediation summary + +## 6) Communication plan + +For enforcement and rollback events, communicate: +- what changed +- expected operator/client action +- duration of temporary compatibility mode (if any) +- verification commands for users + +Recommended channels: +- GitHub release notes +- repository README/docs updates +- issue/incident report in repository + +## 7) Go/No-Go checklist + +Go only if all are true: +- signing workflow success rate is stable +- signatures are mirrored to all documented feed endpoints +- consumer verification path tested for remote + local fallback +- rollback owner is assigned and reachable +- key rotation procedure has been dry-run at least once diff --git a/README.md b/README.md index 4c9199d..6399958 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,6 @@
-

We are featured on Product Hunt - upvote us and help us spread the word.

- -ClawSec by Prompt Security - A Security Skill Suite for OpenClaw Agents | Product Hunt - ## Secure Your OpenClaw Bots with a Complete Security Skill Suite

Brought to you by Prompt Security, the Platform for AI Security

@@ -29,7 +25,7 @@ [![CI](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml) [![Deploy Pages](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml) [![Poll NVD CVEs](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml) -[![Skill Release](https://github.com/prompt-security/clawsec/actions/workflows/skill-release.yml/badge.svg)](https://github.com/prompt-security/clawsec/actions/workflows/skill-release.yml) +
@@ -202,6 +198,12 @@ Each skill release includes: - `SKILL.md` - Main skill documentation - Additional files from SBOM (scripts, configs, etc.) +### Signing Operations Documentation + +For feed/release signing rollout and operations guidance: +- [`SECURITY-SIGNING.md`](SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response +- [`MIGRATION-SIGNED-FEED.md`](MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan + --- ## 🛠️ Offline Tools diff --git a/SECURITY-SIGNING.md b/SECURITY-SIGNING.md new file mode 100644 index 0000000..14321ee --- /dev/null +++ b/SECURITY-SIGNING.md @@ -0,0 +1,215 @@ +# ClawSec Signing Operations Runbook + +## 1) Purpose + +This runbook defines operational procedures for introducing and running cryptographic signing in the ClawSec repository. + +It covers: +- key generation +- GitHub secret management +- signing workflow integration +- key rotation and revocation +- incident response + +## 2) Current branch reality (important) + +As of branch `integration/signing-work`, advisory distribution is **unsigned**: + +- Feed writers: + - `.github/workflows/poll-nvd-cves.yml` writes `advisories/feed.json` and `skills/clawsec-feed/advisories/feed.json` + - `.github/workflows/community-advisory.yml` writes the same files +- Feed publish path: + - `.github/workflows/deploy-pages.yml` copies to `public/advisories/feed.json` + - also mirrors to `public/releases/latest/download/advisories/feed.json` +- Feed consumers: + - `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts` + - `skills/clawsec-suite/scripts/guarded_skill_install.mjs` + - both default to `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json` + +This document defines the **target operating model** for signed artifacts while preserving compatibility during migration. + +## 3) Target signed artifacts + +### Advisory feed channel +- `advisories/feed.json` (payload) +- `advisories/feed.json.sig` (detached Ed25519 signature; base64) +- `advisories/feed-signing-public.pem` (pinned public key) + +### Release artifact channel (recommended) +- `/checksums.json` +- `/checksums.json.sig` +- `advisories/release-signing-public.pem` (or equivalent repo-pinned location) + +## 4) Key roles and custody + +- **Security owner**: approves key lifecycle changes and incident actions. +- **Platform owner**: maintains workflows and GitHub secrets. +- **Reviewer**: validates fingerprints in PRs/releases. + +Policy: +- private keys are never committed +- public keys are committed and code-reviewed +- key generation occurs on trusted operator workstation or HSM-backed environment + +## 5) Key generation (Ed25519) + +> Run from a secure workstation. Do not run on shared CI runners. + +```bash +# Feed signing keypair +openssl genpkey -algorithm Ed25519 -out feed-signing-private.pem +openssl pkey -in feed-signing-private.pem -pubout -out feed-signing-public.pem + +# Release checksums signing keypair (optional separate key) +openssl genpkey -algorithm Ed25519 -out release-signing-private.pem +openssl pkey -in release-signing-private.pem -pubout -out release-signing-public.pem +``` + +Generate fingerprints (store in ticket/change record): + +```bash +openssl pkey -pubin -in feed-signing-public.pem -outform DER | shasum -a 256 +openssl pkey -pubin -in release-signing-public.pem -outform DER | shasum -a 256 +``` + +Optional test-sign before publishing: + +```bash +echo '{"probe":"ok"}' > /tmp/probe.json +openssl pkeyutl -sign -rawin -inkey feed-signing-private.pem -in /tmp/probe.json -out /tmp/probe.sig.bin +openssl base64 -A -in /tmp/probe.sig.bin -out /tmp/probe.sig +openssl base64 -d -A -in /tmp/probe.sig -out /tmp/probe.sig.bin +openssl pkeyutl -verify -rawin -pubin -inkey feed-signing-public.pem -in /tmp/probe.json -sigfile /tmp/probe.sig.bin +``` + +## 6) GitHub secrets setup + +### Required secrets + +- `CLAWSEC_SIGNING_PRIVATE_KEY` — PEM-encoded Ed25519 private key (used for both feed and release signing) +- `CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE` — (optional) passphrase if the private key is encrypted + +### Procedure + +1. Go to **Repo Settings → Secrets and variables → Actions → New repository secret**. +2. Paste full PEM including header/footer. +3. Prefer GitHub **Environment secrets** (with required reviewers) for workflow scoping when possible. +4. Record change ticket with: + - secret name + - creator + - creation time + - key fingerprint + +### Recommended environment protections + +- Require manual approval for workflows that can use signing secrets. +- Restrict who can edit protected workflows. +- Enable branch protection for `main` and require review for workflow changes. + +## 7) Workflow integration points + +This repo already has feed mutation and deployment workflows. Signing should be inserted as a post-mutation, pre-publish control. + +### Feed pipeline + +Current feed mutation points: +- `.github/workflows/poll-nvd-cves.yml` +- `.github/workflows/community-advisory.yml` + +Target addition: +- add signing step/workflow that: + 1. regenerates deterministic feed checksums manifest (optional but recommended) + 2. signs `advisories/feed.json` into `advisories/feed.json.sig` + 3. verifies signature in CI before commit/publish + +### Pages pipeline + +Current publisher: +- `.github/workflows/deploy-pages.yml` + +Target update: +- copy `.sig` files to `public/advisories/` and `public/releases/latest/download/advisories/` +- fail deploy if expected signed companions are missing after migration enforcement date + +### Skill release pipeline (recommended hardening) + +Current release generator: +- `.github/workflows/skill-release.yml` creates `checksums.json` + +Target update: +- sign `checksums.json` before `softprops/action-gh-release` +- attach `checksums.json.sig` to each release + +## 8) Rotation policy and runbook + +### Rotation cadence +- Routine: every 90 days (or stricter org policy). +- Immediate: on suspected exposure, unauthorized workflow change, or unexplained signature mismatch. + +### Routine rotation steps + +1. Generate new keypair(s). +2. Open PR that updates public key file(s) and fingerprints documentation. +3. Add new private key(s) as GitHub secret(s). +4. Merge workflow changes that use new key(s). +5. Re-sign latest feed/release manifests. +6. Validate verification in CI and in one external client. +7. Remove old private key secret(s). +8. Keep old public key reference only as long as required for historical verification. + +### Revocation steps + +1. Disable workflows using compromised key. +2. Remove compromised GitHub secret(s). +3. Commit revocation note and new public key. +4. Re-sign latest artifacts with replacement key. +5. Publish incident advisory with timestamp and impacted window. + +## 9) Incident response playbook (signing-specific) + +### Triggers +- signature verification fails for newly published feed/release +- unknown commits/workflow edits touching signing paths +- leaked key material, accidental logging, or suspicious secret access + +### Severity guide +- **SEV-1**: key exfiltration confirmed or maliciously signed payload published +- **SEV-2**: verification failures with unknown cause +- **SEV-3**: procedural non-compliance, no active compromise + +### Response phases + +1. **Containment** + - pause signing/publish workflows + - block further feed merges if authenticity is uncertain +2. **Investigation** + - review workflow run logs + - review commits affecting `.github/workflows/`, `advisories/`, and key files + - determine first-bad timestamp and affected artifacts +3. **Eradication** + - rotate/revoke compromised key(s) + - restore trusted artifacts from known-good commit +4. **Recovery** + - re-sign artifacts + - redeploy pages/releases + - verify via independent client check +5. **Post-incident** + - publish timeline and remediation summary + - tighten controls (review gates, protected environments, secret scope) + +## 10) Audit evidence checklist + +For each release cycle or feed-signing run, retain: +- workflow run URL and commit SHA +- signer key fingerprint in use +- verification result logs +- operator/reviewer approvals +- any exception or bypass rationale + +## 11) Minimum acceptance criteria before enforcement + +Before requiring signatures in all clients: +- signed artifacts are produced consistently for at least 2 weeks +- deploy pipeline mirrors signature companions +- one rollback drill and one key rotation drill completed successfully +- incident response on-call owner identified and documented diff --git a/pages/Home.tsx b/pages/Home.tsx index 4bdc998..cae9913 100644 --- a/pages/Home.tsx +++ b/pages/Home.tsx @@ -220,24 +220,6 @@ export const Home: React.FC = () => { -
-

- We are featured on Product Hunt - upvote us and help us spread the word. -

- - ClawSec by Prompt Security - A Security Skill Suite for OpenClaw Agents | Product Hunt - -