Integration/signing work (#20)

* ci: sign advisory feed and checksums in workflows

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

Implements cryptographic verification for advisory feed loading:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: remove Product Hunt promotion section from README and Home page
This commit is contained in:
davida-ps
2026-02-12 17:49:34 +01:00
committed by GitHub
parent 331219eec3
commit 5ee8587b1e
24 changed files with 2970 additions and 153 deletions
+27 -12
View File
@@ -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
+53 -16
View File
@@ -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 }}';
+149 -34
View File
@@ -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
+49 -5
View File
@@ -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: |
+217 -10
View File
@@ -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