mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
Integration/signing work (#20)
* ci: sign advisory feed and checksums in workflows * feat(clawsec-suite): add verifier-side signature and checksum enforcement Implements cryptographic verification for advisory feed loading: - Ed25519 detached signature verification for feed.json - Supports raw base64 and JSON-wrapped signature formats - Pinned public key at advisories/feed-signing-public.pem - SHA-256 checksum manifest (checksums.json) verification - Signed checksums.json.sig prevents partial artifact substitution - Verifies feed.json, feed.json.sig, and public key against manifest - Remote feed: returns null on verification failure (triggers fallback) - Local feed: throws on verification failure (hard fail) - No silent bypass of verification - CLAWSEC_ALLOW_UNSIGNED_FEED=1 temporarily bypasses verification - Warning logged when bypass mode is enabled - Intended for transition period only - guarded_skill_install without --version matches any advisory for skill - Encourages explicit version specification - scripts/sign_detached_ed25519.mjs - signing utility - scripts/verify_detached_ed25519.mjs - verification utility - scripts/generate_checksums_json.mjs - checksum manifest generator - test/feed_verification.test.mjs - 14 verification tests - test/guarded_install.test.mjs - 6 install flow tests - hooks/.../lib/feed.mjs - full rewrite with verification - hooks/.../handler.ts - verification options integration - scripts/guarded_skill_install.mjs - verification integration - skill.json - v0.0.9, new SBOM entries, openssl requirement - SKILL.md - signed install flow, env vars documentation - HOOK.md - new environment variables - ci.yml - added verification test job Refs: fail-closed verification, Ed25519 signatures, checksum manifests * fix: update action versions in CI workflows for improved stability * chore(clawsec-suite): bump version to 0.0.10 * feat: enhance security measures in asset deployment and add changelog for version history * feat: add dry-run signing for advisory artifacts and generate checksums * fix: enhance error handling in loadRemoteFeed for security policy violations * feat: implement Ed25519 signing and verification for advisory artifacts and checksums * feat: implement signing and verification for advisory artifacts and checksums in workflows * feat: update dry-run signing key generation to use Ed25519 algorithm * feat: update Ed25519 signing and verification to use -rawin flag for compatibility * feat: add public key copying to advisory directory and implement safe basename extraction for URLs * feat: remove Product Hunt promotion section from README and Home page
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
name: Sign and Verify File
|
||||||
|
description: Sign a file with the CI private key and verify the signature against one or more files.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
private_key:
|
||||||
|
description: PEM-encoded private key contents.
|
||||||
|
required: true
|
||||||
|
private_key_passphrase:
|
||||||
|
description: Optional passphrase for encrypted private keys.
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
input_file:
|
||||||
|
description: File to sign.
|
||||||
|
required: true
|
||||||
|
signature_file:
|
||||||
|
description: Output path for base64 signature.
|
||||||
|
required: true
|
||||||
|
verify_files:
|
||||||
|
description: Newline-separated list of files to verify with the generated signature. Defaults to input_file.
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
public_key_output:
|
||||||
|
description: Optional output path for the derived public key.
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
signature_file:
|
||||||
|
description: Signature file path.
|
||||||
|
value: ${{ steps.sign.outputs.signature_file }}
|
||||||
|
public_key_file:
|
||||||
|
description: Public key file path when public_key_output is set.
|
||||||
|
value: ${{ steps.sign.outputs.public_key_file }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- id: sign
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PRIVATE_KEY: ${{ inputs.private_key }}
|
||||||
|
PRIVATE_KEY_PASSPHRASE: ${{ inputs.private_key_passphrase }}
|
||||||
|
INPUT_FILE: ${{ inputs.input_file }}
|
||||||
|
SIGNATURE_FILE: ${{ inputs.signature_file }}
|
||||||
|
VERIFY_FILES_RAW: ${{ inputs.verify_files }}
|
||||||
|
PUBLIC_KEY_OUTPUT: ${{ inputs.public_key_output }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${PRIVATE_KEY:-}" ]; then
|
||||||
|
echo "::error::Missing required private key input."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "${INPUT_FILE}" ]; then
|
||||||
|
echo "::error file=${INPUT_FILE}::Input file not found for signing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "${tmp_dir}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
key_file="${tmp_dir}/private.pem"
|
||||||
|
pub_file="${tmp_dir}/public.pem"
|
||||||
|
sig_bin="${tmp_dir}/signature.bin"
|
||||||
|
|
||||||
|
printf '%s' "${PRIVATE_KEY}" > "${key_file}"
|
||||||
|
|
||||||
|
passin_args=()
|
||||||
|
if [ -n "${PRIVATE_KEY_PASSPHRASE:-}" ]; then
|
||||||
|
passin_args=(-passin "pass:${PRIVATE_KEY_PASSPHRASE}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
openssl pkey -in "${key_file}" "${passin_args[@]}" -pubout -out "${pub_file}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${SIGNATURE_FILE}")"
|
||||||
|
# Sign with Ed25519 (requires -rawin flag for raw input)
|
||||||
|
openssl pkeyutl -sign -rawin -inkey "${key_file}" "${passin_args[@]}" -in "${INPUT_FILE}" \
|
||||||
|
| openssl base64 -A > "${SIGNATURE_FILE}"
|
||||||
|
|
||||||
|
openssl base64 -d -A -in "${SIGNATURE_FILE}" -out "${sig_bin}"
|
||||||
|
|
||||||
|
verify_files="${VERIFY_FILES_RAW}"
|
||||||
|
if [ -z "${verify_files}" ]; then
|
||||||
|
verify_files="${INPUT_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r verify_file; do
|
||||||
|
[ -z "${verify_file}" ] && continue
|
||||||
|
if [ ! -f "${verify_file}" ]; then
|
||||||
|
echo "::error file=${verify_file}::Verification target does not exist."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Verify Ed25519 signature (requires -rawin flag for raw input)
|
||||||
|
openssl pkeyutl -verify -rawin -pubin -inkey "${pub_file}" -sigfile "${sig_bin}" -in "${verify_file}" >/dev/null
|
||||||
|
done <<< "${verify_files}"
|
||||||
|
|
||||||
|
if [ -n "${PUBLIC_KEY_OUTPUT}" ]; then
|
||||||
|
mkdir -p "$(dirname "${PUBLIC_KEY_OUTPUT}")"
|
||||||
|
cp "${pub_file}" "${PUBLIC_KEY_OUTPUT}"
|
||||||
|
echo "public_key_file=${PUBLIC_KEY_OUTPUT}" >> "${GITHUB_OUTPUT}"
|
||||||
|
else
|
||||||
|
echo "public_key_file=" >> "${GITHUB_OUTPUT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "signature_file=${SIGNATURE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ruff==0.6.9
|
||||||
|
bandit==1.7.9
|
||||||
+27
-12
@@ -11,8 +11,8 @@ jobs:
|
|||||||
name: Lint TypeScript/React
|
name: Lint TypeScript/React
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -28,12 +28,14 @@ jobs:
|
|||||||
name: Lint Python
|
name: Lint Python
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: '.github/requirements-lint-python.txt'
|
||||||
- name: Install linters
|
- name: Install linters
|
||||||
run: pip install ruff bandit
|
run: python -m pip install -r .github/requirements-lint-python.txt
|
||||||
- name: Ruff (lint + format check)
|
- name: Ruff (lint + format check)
|
||||||
run: ruff check utils/ --output-format=github
|
run: ruff check utils/ --output-format=github
|
||||||
- name: Bandit (security)
|
- name: Bandit (security)
|
||||||
@@ -43,9 +45,9 @@ jobs:
|
|||||||
name: Lint Shell Scripts
|
name: Lint Shell Scripts
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: ShellCheck
|
- name: ShellCheck
|
||||||
uses: ludeeus/action-shellcheck@master
|
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
|
||||||
with:
|
with:
|
||||||
scandir: './scripts'
|
scandir: './scripts'
|
||||||
severity: warning
|
severity: warning
|
||||||
@@ -54,9 +56,9 @@ jobs:
|
|||||||
name: Security Scan
|
name: Security Scan
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Trivy FS Scan
|
- name: Trivy FS Scan
|
||||||
uses: aquasecurity/trivy-action@master
|
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
||||||
with:
|
with:
|
||||||
scan-type: 'fs'
|
scan-type: 'fs'
|
||||||
scan-ref: '.'
|
scan-ref: '.'
|
||||||
@@ -64,7 +66,7 @@ jobs:
|
|||||||
exit-code: '1'
|
exit-code: '1'
|
||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
- name: Trivy Config Scan
|
- name: Trivy Config Scan
|
||||||
uses: aquasecurity/trivy-action@master
|
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
||||||
with:
|
with:
|
||||||
scan-type: 'config'
|
scan-type: 'config'
|
||||||
scan-ref: '.'
|
scan-ref: '.'
|
||||||
@@ -75,8 +77,8 @@ jobs:
|
|||||||
name: Dependency Audit
|
name: Dependency Audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -85,3 +87,16 @@ jobs:
|
|||||||
run: npm audit --audit-level=high --registry=https://registry.npmjs.org
|
run: npm audit --audit-level=high --registry=https://registry.npmjs.org
|
||||||
- name: Check for outdated deps
|
- name: Check for outdated deps
|
||||||
run: npm outdated || true
|
run: npm outdated || true
|
||||||
|
|
||||||
|
clawsec-suite-tests:
|
||||||
|
name: ClawSec Suite Verification Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: Feed Verification Tests
|
||||||
|
run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||||
|
- name: Guarded Install Tests
|
||||||
|
run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
issues: write
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: community-advisory
|
group: community-advisory
|
||||||
@@ -14,7 +15,9 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FEED_PATH: advisories/feed.json
|
FEED_PATH: advisories/feed.json
|
||||||
|
FEED_SIG_PATH: advisories/feed.json.sig
|
||||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||||
|
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
process-advisory:
|
process-advisory:
|
||||||
@@ -22,7 +25,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -196,46 +199,80 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Commit changes
|
- name: Sign advisory feed and verify
|
||||||
if: steps.parse.outputs.already_exists != 'true'
|
if: steps.parse.outputs.already_exists != 'true'
|
||||||
run: |
|
uses: ./.github/actions/sign-and-verify
|
||||||
git config user.name "github-actions[bot]"
|
with:
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||||
|
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||||
|
input_file: ${{ env.FEED_PATH }}
|
||||||
|
signature_file: ${{ env.FEED_SIG_PATH }}
|
||||||
|
verify_files: |
|
||||||
|
${{ env.FEED_PATH }}
|
||||||
|
${{ env.SKILL_FEED_PATH }}
|
||||||
|
|
||||||
git add "$FEED_PATH" "$SKILL_FEED_PATH"
|
- name: Sync advisory signature to skill feed
|
||||||
|
if: steps.parse.outputs.already_exists != 'true'
|
||||||
|
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||||
|
|
||||||
ADVISORY_ID="${{ steps.parse.outputs.advisory_id }}"
|
- name: Create Pull Request
|
||||||
git commit -m "chore: add community advisory $ADVISORY_ID
|
if: steps.parse.outputs.already_exists != 'true'
|
||||||
|
id: create-pr
|
||||||
|
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
branch: automated/community-advisory-${{ github.event.issue.number }}
|
||||||
|
delete-branch: true
|
||||||
|
title: "chore: add community advisory ${{ steps.parse.outputs.advisory_id }}"
|
||||||
|
body: |
|
||||||
|
## Summary
|
||||||
|
Add community advisory `${{ steps.parse.outputs.advisory_id }}` from issue #${{ github.event.issue.number }}.
|
||||||
|
|
||||||
Added from issue #${{ github.event.issue.number }}
|
- Issue: ${{ github.event.issue.html_url }}
|
||||||
Issue: ${{ github.event.issue.html_url }}"
|
- Reporter: @${{ github.event.issue.user.login }}
|
||||||
|
- Trigger: `advisory-approved` label
|
||||||
|
|
||||||
git push
|
---
|
||||||
|
*This PR was generated by the community advisory workflow.*
|
||||||
|
commit-message: |
|
||||||
|
chore: add community advisory ${{ steps.parse.outputs.advisory_id }}
|
||||||
|
|
||||||
|
Added from issue #${{ github.event.issue.number }}
|
||||||
|
Issue: ${{ github.event.issue.html_url }}
|
||||||
|
add-paths: |
|
||||||
|
${{ env.FEED_PATH }}
|
||||||
|
${{ env.FEED_SIG_PATH }}
|
||||||
|
${{ env.SKILL_FEED_PATH }}
|
||||||
|
${{ env.SKILL_FEED_SIG_PATH }}
|
||||||
|
|
||||||
- name: Comment on issue
|
- name: Comment on issue
|
||||||
if: steps.parse.outputs.already_exists != 'true'
|
if: steps.parse.outputs.already_exists != 'true'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
||||||
|
const pullRequestUrl = '${{ steps.create-pr.outputs.pull-request-url }}';
|
||||||
|
const operation = '${{ steps.create-pr.outputs.pull-request-operation }}';
|
||||||
await github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: context.issue.number,
|
issue_number: context.issue.number,
|
||||||
body: `## Advisory Published
|
body: `## Advisory Pull Request Opened
|
||||||
|
|
||||||
This security report has been published to the ClawSec advisory feed.
|
This security report has been prepared for publication in the ClawSec advisory feed.
|
||||||
|
|
||||||
**Advisory ID:** \`${advisoryId}\`
|
**Advisory ID:** \`${advisoryId}\`
|
||||||
|
**Pull Request:** ${pullRequestUrl || 'No PR generated (no file changes detected)'}
|
||||||
|
**PR Operation:** \`${operation || 'none'}\`
|
||||||
|
|
||||||
The advisory is now available in the feed and will be picked up by agents on their next feed check.
|
The advisory will be published after the pull request is merged.
|
||||||
|
|
||||||
Thank you for your contribution to community security!`
|
Thank you for your contribution to community security!`
|
||||||
});
|
});
|
||||||
|
|
||||||
- name: Comment if already exists
|
- name: Comment if already exists
|
||||||
if: steps.parse.outputs.already_exists == 'true'
|
if: steps.parse.outputs.already_exists == 'true'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ jobs:
|
|||||||
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Auto-discover skills from releases
|
- name: Auto-discover skills from releases
|
||||||
env:
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
@@ -48,17 +49,17 @@ jobs:
|
|||||||
}
|
}
|
||||||
export -f download_asset # Export for use in subshells (while loop)
|
export -f download_asset # Export for use in subshells (while loop)
|
||||||
|
|
||||||
# Fetch all releases
|
# Fetch all releases (paginated)
|
||||||
RELEASES=$(curl -sSL \
|
RELEASES=$(gh api --paginate \
|
||||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
"https://api.github.com/repos/${REPO}/releases?per_page=100")
|
"/repos/${REPO}/releases?per_page=100" \
|
||||||
|
| jq -s 'add // []')
|
||||||
|
|
||||||
# Start building skills index
|
# Start building skills index
|
||||||
echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json
|
echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json
|
||||||
|
|
||||||
FIRST_SKILL=true
|
FIRST_SKILL=true
|
||||||
PROCESSED_SKILLS=""
|
declare -A PROCESSED_SKILLS=()
|
||||||
|
|
||||||
# Process each release (using process substitution to avoid subshell)
|
# Process each release (using process substitution to avoid subshell)
|
||||||
while read -r release; do
|
while read -r release; do
|
||||||
@@ -70,7 +71,7 @@ jobs:
|
|||||||
VERSION="${BASH_REMATCH[2]}"
|
VERSION="${BASH_REMATCH[2]}"
|
||||||
|
|
||||||
# Skip if we already processed a newer version of this skill
|
# Skip if we already processed a newer version of this skill
|
||||||
if echo "$PROCESSED_SKILLS" | grep -q "^${SKILL_NAME}$"; then
|
if [[ -n "${PROCESSED_SKILLS[$SKILL_NAME]+x}" ]]; then
|
||||||
echo "Skipping older version: $TAG (already have newer)"
|
echo "Skipping older version: $TAG (already have newer)"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
@@ -99,13 +100,16 @@ jobs:
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Mirror all release assets under a GitHub-compatible path so users can
|
# Security: Download to temp directory first, verify signatures, then mirror to final location.
|
||||||
# swap the host (github.com → clawsec.prompt.security) if GitHub is blocked.
|
# This ensures unverified releases never appear in public/releases or the skills catalog.
|
||||||
MIRROR_DIR="public/releases/download/${TAG}"
|
|
||||||
mkdir -p "$MIRROR_DIR"
|
|
||||||
mv "$SKILL_JSON_TMP" "$MIRROR_DIR/skill.json"
|
|
||||||
|
|
||||||
# Download all remaining assets for this release (retain asset names)
|
# Use temp directory for downloads before verification
|
||||||
|
TEMP_DOWNLOAD_DIR=$(mktemp -d)
|
||||||
|
|
||||||
|
# Move skill.json to temp dir first
|
||||||
|
mv "$SKILL_JSON_TMP" "$TEMP_DOWNLOAD_DIR/skill.json"
|
||||||
|
|
||||||
|
# Download all remaining assets to temp dir
|
||||||
while read -r asset; do
|
while read -r asset; do
|
||||||
ASSET_ID=$(echo "$asset" | jq -r '.id')
|
ASSET_ID=$(echo "$asset" | jq -r '.id')
|
||||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||||
@@ -121,16 +125,41 @@ jobs:
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
download_asset "$ASSET_ID" "$MIRROR_DIR/$ASSET_NAME"
|
download_asset "$ASSET_ID" "$TEMP_DOWNLOAD_DIR/$ASSET_NAME"
|
||||||
echo " Mirrored: $ASSET_NAME"
|
echo " Downloaded to temp: $ASSET_NAME"
|
||||||
done < <(echo "$release" | jq -c '.assets[]')
|
done < <(echo "$release" | jq -c '.assets[]')
|
||||||
|
|
||||||
|
# Verify signed checksums when signature artifacts are present.
|
||||||
|
# Legacy releases without signatures are still mirrored for backward compatibility.
|
||||||
|
if [ -f "$TEMP_DOWNLOAD_DIR/checksums.sig" ] && [ -f "$TEMP_DOWNLOAD_DIR/signing-public.pem" ] && [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
|
||||||
|
openssl base64 -d -A -in "$TEMP_DOWNLOAD_DIR/checksums.sig" -out "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
|
||||||
|
# Verify Ed25519 signature (requires -rawin)
|
||||||
|
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$TEMP_DOWNLOAD_DIR/signing-public.pem" -sigfile "$TEMP_DOWNLOAD_DIR/checksums.sig.bin" -in "$TEMP_DOWNLOAD_DIR/checksums.json"; then
|
||||||
|
echo " Warning: Invalid checksums signature for $TAG; skipping skill"
|
||||||
|
rm -rf "$TEMP_DOWNLOAD_DIR"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -f "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
|
||||||
|
echo " Verified checksums signature"
|
||||||
|
elif [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
|
||||||
|
echo " Warning: Unsigned legacy checksums for $TAG (missing checksums.sig/signing-public.pem)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verification passed or skipped (legacy) - mirror to final location
|
||||||
|
MIRROR_DIR="public/releases/download/${TAG}"
|
||||||
|
mkdir -p "$MIRROR_DIR"
|
||||||
|
cp -r "$TEMP_DOWNLOAD_DIR"/* "$MIRROR_DIR"/
|
||||||
|
echo " Mirrored to: $MIRROR_DIR"
|
||||||
|
|
||||||
|
# Clean up temp directory
|
||||||
|
rm -rf "$TEMP_DOWNLOAD_DIR"
|
||||||
|
|
||||||
# Copy the subset needed for the site catalog (skill pages)
|
# Copy the subset needed for the site catalog (skill pages)
|
||||||
mkdir -p "public/skills/${SKILL_NAME}"
|
mkdir -p "public/skills/${SKILL_NAME}"
|
||||||
cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json"
|
cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json"
|
||||||
echo " Added to catalog: skill.json"
|
echo " Added to catalog: skill.json"
|
||||||
|
|
||||||
for file in checksums.json README.md SKILL.md; do
|
for file in checksums.json checksums.sig signing-public.pem README.md SKILL.md; do
|
||||||
if [ -f "$MIRROR_DIR/$file" ]; then
|
if [ -f "$MIRROR_DIR/$file" ]; then
|
||||||
cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file"
|
cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file"
|
||||||
echo " Added to catalog: $file"
|
echo " Added to catalog: $file"
|
||||||
@@ -158,7 +187,7 @@ jobs:
|
|||||||
echo "$SKILL_DATA" >> public/skills/index.json
|
echo "$SKILL_DATA" >> public/skills/index.json
|
||||||
|
|
||||||
# Mark this skill as processed (track newest only)
|
# Mark this skill as processed (track newest only)
|
||||||
PROCESSED_SKILLS="${PROCESSED_SKILLS}${SKILL_NAME}\n"
|
PROCESSED_SKILLS["$SKILL_NAME"]=1
|
||||||
else
|
else
|
||||||
echo " Warning: skill.json not found in release assets"
|
echo " Warning: skill.json not found in release assets"
|
||||||
fi
|
fi
|
||||||
@@ -179,35 +208,105 @@ jobs:
|
|||||||
echo "=== Skills Directory ==="
|
echo "=== Skills Directory ==="
|
||||||
ls -la public/skills/
|
ls -la public/skills/
|
||||||
|
|
||||||
- name: Create root checksums placeholder
|
|
||||||
run: |
|
|
||||||
# Create empty checksums.json placeholder for root level
|
|
||||||
echo '{"version":"1.0.0","files":{}}' > public/checksums.json
|
|
||||||
echo "Created checksums.json placeholder"
|
|
||||||
|
|
||||||
- name: Copy advisory feed to public
|
- name: Copy advisory feed to public
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir -p public/advisories
|
mkdir -p public/advisories
|
||||||
cp advisories/feed.json public/advisories/feed.json
|
cp advisories/feed.json public/advisories/feed.json
|
||||||
echo "Copied advisory feed to public/advisories/"
|
echo "Copied advisory feed to public/advisories/"
|
||||||
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
|
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
|
||||||
|
|
||||||
|
- name: Generate advisory checksums manifest
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FEED_FILE="public/advisories/feed.json"
|
||||||
|
FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}')
|
||||||
|
FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE")
|
||||||
|
|
||||||
|
# Generate checksums manifest conforming to parseChecksumsManifest expectations:
|
||||||
|
# - schema_version: "1" (manifest format version)
|
||||||
|
# - algorithm: "sha256" (hash algorithm)
|
||||||
|
# - version: "1.1.0" (feed content version, for informational purposes)
|
||||||
|
# - generated_at, repository: metadata
|
||||||
|
# - files: map of path -> {sha256, size, path, url}
|
||||||
|
jq -n \
|
||||||
|
--arg schema_version "1" \
|
||||||
|
--arg algorithm "sha256" \
|
||||||
|
--arg version "1.1.0" \
|
||||||
|
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--arg repo "${{ github.repository }}" \
|
||||||
|
--arg sha "$FEED_SHA" \
|
||||||
|
--argjson size "$FEED_SIZE" \
|
||||||
|
'{
|
||||||
|
schema_version: $schema_version,
|
||||||
|
algorithm: $algorithm,
|
||||||
|
version: $version,
|
||||||
|
generated_at: $generated,
|
||||||
|
repository: $repo,
|
||||||
|
files: {
|
||||||
|
"advisories/feed.json": {
|
||||||
|
sha256: $sha,
|
||||||
|
size: $size,
|
||||||
|
path: "advisories/feed.json",
|
||||||
|
url: "https://clawsec.prompt.security/advisories/feed.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' > public/checksums.json
|
||||||
|
|
||||||
|
echo "Generated public/checksums.json"
|
||||||
|
jq . public/checksums.json
|
||||||
|
|
||||||
|
- name: Sign advisory feed and verify
|
||||||
|
uses: ./.github/actions/sign-and-verify
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||||
|
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||||
|
input_file: public/advisories/feed.json
|
||||||
|
signature_file: public/advisories/feed.json.sig
|
||||||
|
public_key_output: public/signing-public.pem
|
||||||
|
|
||||||
|
- name: Sign checksums and verify
|
||||||
|
uses: ./.github/actions/sign-and-verify
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||||
|
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||||
|
input_file: public/checksums.json
|
||||||
|
signature_file: public/checksums.sig
|
||||||
|
|
||||||
|
- name: Copy public key to advisory directory
|
||||||
|
run: |
|
||||||
|
# Clients expect the public key at advisories/feed-signing-public.pem
|
||||||
|
mkdir -p public/advisories
|
||||||
|
cp public/signing-public.pem public/advisories/feed-signing-public.pem
|
||||||
|
echo "Public key available at:"
|
||||||
|
echo " - public/signing-public.pem (root)"
|
||||||
|
echo " - public/advisories/feed-signing-public.pem (advisory-specific)"
|
||||||
|
|
||||||
|
- name: Show signed advisory artifacts
|
||||||
|
run: |
|
||||||
|
echo "Signed advisory artifacts:"
|
||||||
|
ls -la public/advisories/feed.json*
|
||||||
|
ls -la public/checksums.json public/checksums.sig public/signing-public.pem
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Get latest clawsec-suite release URL
|
- name: Get latest clawsec-suite release URL
|
||||||
env:
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
LATEST_TAG=$(curl -sSL \
|
LATEST_TAG=$(
|
||||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
gh api --paginate \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
"https://api.github.com/repos/${REPO}/releases?per_page=100" | \
|
"/repos/${REPO}/releases?per_page=100" \
|
||||||
jq -r '[.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty')
|
| jq -r -s 'add // [] | [.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty'
|
||||||
|
)
|
||||||
|
|
||||||
if [ -n "$LATEST_TAG" ]; then
|
if [ -n "$LATEST_TAG" ]; then
|
||||||
echo "Found latest clawsec-suite tag: $LATEST_TAG"
|
echo "Found latest clawsec-suite tag: $LATEST_TAG"
|
||||||
@@ -229,12 +328,26 @@ jobs:
|
|||||||
echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)"
|
echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Mirror advisories feed at the path referenced by suite docs/heartbeat
|
# Mirror advisories feed + signatures at the path referenced by suite docs/heartbeat
|
||||||
if [ -f "public/advisories/feed.json" ]; then
|
if [ -f "public/advisories/feed.json" ]; then
|
||||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||||
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/advisories/feed.json"
|
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/advisories/feed.json"
|
||||||
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json"
|
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json"
|
||||||
fi
|
fi
|
||||||
|
if [ -f "public/advisories/feed.json.sig" ]; then
|
||||||
|
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||||
|
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/advisories/feed.json.sig"
|
||||||
|
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/feed.json.sig"
|
||||||
|
fi
|
||||||
|
if [ -f "public/checksums.json" ]; then
|
||||||
|
cp "public/checksums.json" "$MIRROR_LATEST_DIR/checksums.json"
|
||||||
|
fi
|
||||||
|
if [ -f "public/checksums.sig" ]; then
|
||||||
|
cp "public/checksums.sig" "$MIRROR_LATEST_DIR/checksums.sig"
|
||||||
|
fi
|
||||||
|
if [ -f "public/signing-public.pem" ]; then
|
||||||
|
cp "public/signing-public.pem" "$MIRROR_LATEST_DIR/signing-public.pem"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "No clawsec-suite release found, using fallback"
|
echo "No clawsec-suite release found, using fallback"
|
||||||
fi
|
fi
|
||||||
@@ -251,7 +364,9 @@ jobs:
|
|||||||
- name: Copy skills data to dist
|
- name: Copy skills data to dist
|
||||||
run: |
|
run: |
|
||||||
cp -r public/skills dist/skills 2>/dev/null || echo "No skills directory"
|
cp -r public/skills dist/skills 2>/dev/null || echo "No skills directory"
|
||||||
cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No legacy checksums"
|
cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No checksums manifest"
|
||||||
|
cp public/checksums.sig dist/checksums.sig 2>/dev/null || echo "No checksums signature"
|
||||||
|
cp public/signing-public.pem dist/signing-public.pem 2>/dev/null || echo "No signing public key"
|
||||||
cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory"
|
cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory"
|
||||||
|
|
||||||
echo "=== Dist contents ==="
|
echo "=== Dist contents ==="
|
||||||
@@ -263,10 +378,10 @@ jobs:
|
|||||||
run: touch dist/.nojekyll
|
run: touch dist/.nojekyll
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v4
|
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
|
||||||
with:
|
with:
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
@@ -280,4 +395,4 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FEED_PATH: advisories/feed.json
|
FEED_PATH: advisories/feed.json
|
||||||
|
FEED_SIG_PATH: advisories/feed.json.sig
|
||||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||||
|
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||||
KEYWORDS: "OpenClaw clawdbot Moltbot"
|
KEYWORDS: "OpenClaw clawdbot Moltbot"
|
||||||
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
|
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ jobs:
|
|||||||
- name: Fetch CVEs from NVD
|
- name: Fetch CVEs from NVD
|
||||||
id: fetch
|
id: fetch
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir -p tmp
|
mkdir -p tmp
|
||||||
|
|
||||||
START_DATE="${{ steps.dates.outputs.start_date }}"
|
START_DATE="${{ steps.dates.outputs.start_date }}"
|
||||||
@@ -90,6 +93,8 @@ jobs:
|
|||||||
END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
|
END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
|
||||||
|
|
||||||
echo "=== Fetching CVEs from NVD ==="
|
echo "=== Fetching CVEs from NVD ==="
|
||||||
|
|
||||||
|
FAILED_KEYWORDS=()
|
||||||
|
|
||||||
# Fetch for each keyword
|
# Fetch for each keyword
|
||||||
for KEYWORD in $KEYWORDS; do
|
for KEYWORD in $KEYWORDS; do
|
||||||
@@ -99,11 +104,22 @@ jobs:
|
|||||||
echo "URL: $URL"
|
echo "URL: $URL"
|
||||||
|
|
||||||
# Fetch with retry logic
|
# Fetch with retry logic
|
||||||
|
keyword_ok=false
|
||||||
|
last_http_code=""
|
||||||
for i in 1 2 3; do
|
for i in 1 2 3; do
|
||||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL")
|
HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL" || true)
|
||||||
|
if [ -z "$HTTP_CODE" ]; then
|
||||||
|
HTTP_CODE="000"
|
||||||
|
fi
|
||||||
|
last_http_code="$HTTP_CODE"
|
||||||
if [ "$HTTP_CODE" = "200" ]; then
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
echo "Success for $KEYWORD"
|
if jq -e . "tmp/nvd_${KEYWORD}.json" >/dev/null 2>&1; then
|
||||||
break
|
echo "Success for $KEYWORD"
|
||||||
|
keyword_ok=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Invalid JSON for $KEYWORD, retry $i..."
|
||||||
|
sleep 5
|
||||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
|
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
|
||||||
echo "Rate limited, waiting 30s before retry $i..."
|
echo "Rate limited, waiting 30s before retry $i..."
|
||||||
sleep 30
|
sleep 30
|
||||||
@@ -112,11 +128,21 @@ jobs:
|
|||||||
sleep 5
|
sleep 5
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ "$keyword_ok" != "true" ]; then
|
||||||
|
echo "::error::Failed to fetch valid NVD response for keyword '$KEYWORD' (last HTTP code: ${last_http_code:-unknown})."
|
||||||
|
FAILED_KEYWORDS+=("$KEYWORD")
|
||||||
|
fi
|
||||||
|
|
||||||
# NVD recommends 6 second delay between requests
|
# NVD recommends 6 second delay between requests
|
||||||
sleep 6
|
sleep 6
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ "${#FAILED_KEYWORDS[@]}" -gt 0 ]; then
|
||||||
|
echo "::error::NVD fetch failed for keyword(s): ${FAILED_KEYWORDS[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== Fetch complete ==="
|
echo "=== Fetch complete ==="
|
||||||
ls -la tmp/
|
ls -la tmp/
|
||||||
|
|
||||||
@@ -508,6 +534,22 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Sign advisory feed and verify
|
||||||
|
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||||
|
uses: ./.github/actions/sign-and-verify
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||||
|
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||||
|
input_file: ${{ env.FEED_PATH }}
|
||||||
|
signature_file: ${{ env.FEED_SIG_PATH }}
|
||||||
|
verify_files: |
|
||||||
|
${{ env.FEED_PATH }}
|
||||||
|
${{ env.SKILL_FEED_PATH }}
|
||||||
|
|
||||||
|
- name: Sync advisory signature to skill feed
|
||||||
|
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||||
|
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||||
|
|
||||||
- name: Clean workspace for PR
|
- name: Clean workspace for PR
|
||||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||||
run: |
|
run: |
|
||||||
@@ -518,7 +560,7 @@ jobs:
|
|||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||||
id: create-pr
|
id: create-pr
|
||||||
uses: peter-evans/create-pull-request@v7
|
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
branch: automated/nvd-cve-update-${{ github.run_id }}
|
branch: automated/nvd-cve-update-${{ github.run_id }}
|
||||||
@@ -543,7 +585,9 @@ jobs:
|
|||||||
Poll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}
|
Poll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}
|
||||||
add-paths: |
|
add-paths: |
|
||||||
${{ env.FEED_PATH }}
|
${{ env.FEED_PATH }}
|
||||||
|
${{ env.FEED_SIG_PATH }}
|
||||||
${{ env.SKILL_FEED_PATH }}
|
${{ env.SKILL_FEED_PATH }}
|
||||||
|
${{ env.SKILL_FEED_SIG_PATH }}
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -174,10 +174,21 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate test signing key for dry-run
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "Generating temporary Ed25519 test key for dry-run validation"
|
||||||
|
umask 077
|
||||||
|
mkdir -p /tmp/test-signing
|
||||||
|
# Use Ed25519 to match production signing (not RSA)
|
||||||
|
openssl genpkey -algorithm ED25519 -out /tmp/test-signing/private.pem
|
||||||
|
openssl pkey -in /tmp/test-signing/private.pem -pubout -out /tmp/test-signing/public.pem
|
||||||
|
echo "TEST_SIGNING_KEY_DIR=/tmp/test-signing" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Run release dry-run for changed skills
|
- name: Run release dry-run for changed skills
|
||||||
env:
|
env:
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
@@ -185,6 +196,83 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Helper function to sign advisory artifacts with test key (dry-run only)
|
||||||
|
sign_advisory_artifacts() {
|
||||||
|
local skill_dir="$1"
|
||||||
|
local advisory_dir="${skill_dir}/advisories"
|
||||||
|
|
||||||
|
if [ ! -d "$advisory_dir" ] || [ ! -f "$advisory_dir/feed.json" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " [Dry-run] Signing advisory artifacts with test key"
|
||||||
|
|
||||||
|
local key_file="$TEST_SIGNING_KEY_DIR/private.pem"
|
||||||
|
local pub_file="$TEST_SIGNING_KEY_DIR/public.pem"
|
||||||
|
local tmp_sig_bin
|
||||||
|
|
||||||
|
# Sign feed.json with Ed25519 (requires -rawin flag)
|
||||||
|
openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/feed.json" | \
|
||||||
|
openssl base64 -A > "$advisory_dir/feed.json.sig"
|
||||||
|
|
||||||
|
# Verify Ed25519 feed.json signature (requires -rawin flag)
|
||||||
|
tmp_sig_bin=$(mktemp)
|
||||||
|
openssl base64 -d -A -in "$advisory_dir/feed.json.sig" -out "$tmp_sig_bin"
|
||||||
|
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/feed.json" >/dev/null 2>&1; then
|
||||||
|
echo "::error file=${skill_dir}/advisories/feed.json.sig::Feed signature verification failed after signing"
|
||||||
|
rm -f "$tmp_sig_bin"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$tmp_sig_bin"
|
||||||
|
echo " [Dry-run] Verified feed.json signature"
|
||||||
|
|
||||||
|
# Generate checksums.json
|
||||||
|
local feed_sha=$(sha256sum "$advisory_dir/feed.json" | awk '{print $1}')
|
||||||
|
local feed_size=$(stat -c%s "$advisory_dir/feed.json" 2>/dev/null || stat -f%z "$advisory_dir/feed.json")
|
||||||
|
local feed_sig_sha=$(sha256sum "$advisory_dir/feed.json.sig" | awk '{print $1}')
|
||||||
|
local feed_sig_size=$(stat -c%s "$advisory_dir/feed.json.sig" 2>/dev/null || stat -f%z "$advisory_dir/feed.json.sig")
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg schema_version "1" \
|
||||||
|
--arg algorithm "sha256" \
|
||||||
|
--arg version "test" \
|
||||||
|
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--arg feed_sha "$feed_sha" \
|
||||||
|
--argjson feed_size "$feed_size" \
|
||||||
|
--arg feed_sig_sha "$feed_sig_sha" \
|
||||||
|
--argjson feed_sig_size "$feed_sig_size" \
|
||||||
|
'{
|
||||||
|
schema_version: $schema_version,
|
||||||
|
algorithm: $algorithm,
|
||||||
|
version: $version,
|
||||||
|
generated_at: $generated,
|
||||||
|
files: {
|
||||||
|
"advisories/feed.json": {sha256: $feed_sha, size: $feed_size},
|
||||||
|
"advisories/feed.json.sig": {sha256: $feed_sig_sha, size: $feed_sig_size}
|
||||||
|
}
|
||||||
|
}' > "$advisory_dir/checksums.json"
|
||||||
|
|
||||||
|
# Sign checksums.json with Ed25519 (requires -rawin flag)
|
||||||
|
openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/checksums.json" | \
|
||||||
|
openssl base64 -A > "$advisory_dir/checksums.json.sig"
|
||||||
|
|
||||||
|
# Verify Ed25519 checksums.json signature (requires -rawin flag)
|
||||||
|
tmp_sig_bin=$(mktemp)
|
||||||
|
openssl base64 -d -A -in "$advisory_dir/checksums.json.sig" -out "$tmp_sig_bin"
|
||||||
|
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/checksums.json" >/dev/null 2>&1; then
|
||||||
|
echo "::error file=${skill_dir}/advisories/checksums.json.sig::Checksums signature verification failed after signing"
|
||||||
|
rm -f "$tmp_sig_bin"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$tmp_sig_bin"
|
||||||
|
echo " [Dry-run] Verified checksums.json signature"
|
||||||
|
|
||||||
|
# Copy public key
|
||||||
|
cp "$pub_file" "$advisory_dir/feed-signing-public.pem"
|
||||||
|
|
||||||
|
echo " [Dry-run] Advisory artifacts signed and verified with test key"
|
||||||
|
}
|
||||||
|
|
||||||
get_md_version() {
|
get_md_version() {
|
||||||
local md_file="$1"
|
local md_file="$1"
|
||||||
awk '
|
awk '
|
||||||
@@ -263,6 +351,13 @@ jobs:
|
|||||||
out_assets="${out_root}/release-assets"
|
out_assets="${out_root}/release-assets"
|
||||||
mkdir -p "${out_assets}"
|
mkdir -p "${out_assets}"
|
||||||
|
|
||||||
|
# --- Sign advisory artifacts if present (dry-run with test key) ---
|
||||||
|
if ! sign_advisory_artifacts "${skill_dir}"; then
|
||||||
|
failures=$((failures + 1))
|
||||||
|
echo "::endgroup::"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Stage SBOM files preserving directory structure ---
|
# --- Stage SBOM files preserving directory structure ---
|
||||||
staging_dir="$(mktemp -d)"
|
staging_dir="$(mktemp -d)"
|
||||||
inner_dir="${staging_dir}/${skill_name}"
|
inner_dir="${staging_dir}/${skill_name}"
|
||||||
@@ -284,10 +379,28 @@ jobs:
|
|||||||
|
|
||||||
cp "${json_path}" "${inner_dir}/skill.json"
|
cp "${json_path}" "${inner_dir}/skill.json"
|
||||||
|
|
||||||
|
# --- Remove test-only artifacts from staging (don't include in release zip) ---
|
||||||
|
# The test signatures/keys were needed for SBOM validation but shouldn't ship
|
||||||
|
if [ -d "${inner_dir}/advisories" ]; then
|
||||||
|
rm -f "${inner_dir}/advisories/feed.json.sig"
|
||||||
|
rm -f "${inner_dir}/advisories/checksums.json"
|
||||||
|
rm -f "${inner_dir}/advisories/checksums.json.sig"
|
||||||
|
rm -f "${inner_dir}/advisories/feed-signing-public.pem"
|
||||||
|
echo " [Dry-run] Removed test signatures from release staging"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Create zip preserving directory structure ---
|
# --- Create zip preserving directory structure ---
|
||||||
zip_name="${skill_name}-v${version}.zip"
|
zip_name="${skill_name}-v${version}.zip"
|
||||||
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .)
|
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .)
|
||||||
|
|
||||||
|
# --- Clean up test artifacts from source directory ---
|
||||||
|
if [ -d "${skill_dir}/advisories" ]; then
|
||||||
|
rm -f "${skill_dir}/advisories/feed.json.sig"
|
||||||
|
rm -f "${skill_dir}/advisories/checksums.json"
|
||||||
|
rm -f "${skill_dir}/advisories/checksums.json.sig"
|
||||||
|
rm -f "${skill_dir}/advisories/feed-signing-public.pem"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Generate checksums.json via jq ---
|
# --- Generate checksums.json via jq ---
|
||||||
files_json="{}"
|
files_json="{}"
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
@@ -402,7 +515,7 @@ jobs:
|
|||||||
echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}"
|
echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}"
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Validate skill exists
|
- name: Validate skill exists
|
||||||
run: |
|
run: |
|
||||||
@@ -472,10 +585,79 @@ jobs:
|
|||||||
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
|
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Sign embedded advisory feed and verify
|
||||||
|
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
|
||||||
|
uses: ./.github/actions/sign-and-verify
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||||
|
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||||
|
input_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json
|
||||||
|
signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json.sig
|
||||||
|
public_key_output: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed-signing-public.pem
|
||||||
|
|
||||||
|
- name: Generate embedded advisory checksums manifest
|
||||||
|
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories"
|
||||||
|
FEED_SHA=$(sha256sum "$ADVISORY_DIR/feed.json" | awk '{print $1}')
|
||||||
|
FEED_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json")
|
||||||
|
FEED_SIG_SHA=$(sha256sum "$ADVISORY_DIR/feed.json.sig" | awk '{print $1}')
|
||||||
|
FEED_SIG_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json.sig" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json.sig")
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg schema_version "1" \
|
||||||
|
--arg algorithm "sha256" \
|
||||||
|
--arg version "${{ steps.parse.outputs.version }}" \
|
||||||
|
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--arg repo "${{ github.repository }}" \
|
||||||
|
--arg feed_sha "$FEED_SHA" \
|
||||||
|
--argjson feed_size "$FEED_SIZE" \
|
||||||
|
--arg feed_sig_sha "$FEED_SIG_SHA" \
|
||||||
|
--argjson feed_sig_size "$FEED_SIG_SIZE" \
|
||||||
|
'{
|
||||||
|
schema_version: $schema_version,
|
||||||
|
algorithm: $algorithm,
|
||||||
|
version: $version,
|
||||||
|
generated_at: $generated,
|
||||||
|
repository: $repo,
|
||||||
|
files: {
|
||||||
|
"advisories/feed.json": {
|
||||||
|
sha256: $feed_sha,
|
||||||
|
size: $feed_size,
|
||||||
|
path: "advisories/feed.json"
|
||||||
|
},
|
||||||
|
"advisories/feed.json.sig": {
|
||||||
|
sha256: $feed_sig_sha,
|
||||||
|
size: $feed_sig_size,
|
||||||
|
path: "advisories/feed.json.sig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' > "$ADVISORY_DIR/checksums.json"
|
||||||
|
|
||||||
|
echo "Generated $ADVISORY_DIR/checksums.json"
|
||||||
|
jq . "$ADVISORY_DIR/checksums.json"
|
||||||
|
|
||||||
|
- name: Sign embedded advisory checksums and verify
|
||||||
|
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
|
||||||
|
uses: ./.github/actions/sign-and-verify
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||||
|
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||||
|
input_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json
|
||||||
|
signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json.sig
|
||||||
|
|
||||||
|
- name: Show embedded advisory signing outputs
|
||||||
|
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
|
||||||
|
run: |
|
||||||
|
ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories"
|
||||||
|
echo "Successfully signed embedded advisory artifacts:"
|
||||||
|
ls -la "$ADVISORY_DIR"
|
||||||
|
|
||||||
- name: Install clawhub CLI
|
- name: Install clawhub CLI
|
||||||
if: steps.publishable.outputs.publishable == 'true'
|
if: steps.publishable.outputs.publishable == 'true'
|
||||||
run: npm install -g clawhub
|
run: npm install -g clawhub
|
||||||
@@ -597,6 +779,20 @@ jobs:
|
|||||||
echo "=== Release assets ==="
|
echo "=== Release assets ==="
|
||||||
ls -la release-assets/
|
ls -la release-assets/
|
||||||
|
|
||||||
|
- name: Sign checksums and verify
|
||||||
|
uses: ./.github/actions/sign-and-verify
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||||
|
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||||
|
input_file: release-assets/checksums.json
|
||||||
|
signature_file: release-assets/checksums.sig
|
||||||
|
public_key_output: release-assets/signing-public.pem
|
||||||
|
|
||||||
|
- name: Show signed release assets
|
||||||
|
run: |
|
||||||
|
echo "Signed and verified release-assets/checksums.json"
|
||||||
|
ls -la release-assets/
|
||||||
|
|
||||||
- name: Publish to ClawHub
|
- name: Publish to ClawHub
|
||||||
if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
|
if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
|
||||||
run: |
|
run: |
|
||||||
@@ -620,7 +816,7 @@ jobs:
|
|||||||
--no-input
|
--no-input
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
with:
|
with:
|
||||||
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
|
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
@@ -637,22 +833,33 @@ jobs:
|
|||||||
|
|
||||||
**Manual download with verification:**
|
**Manual download with verification:**
|
||||||
```bash
|
```bash
|
||||||
# 1. Download the release archive and checksums
|
# 1. Download the release archive, checksums, and signing material
|
||||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
|
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
|
||||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
|
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
|
||||||
|
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
|
||||||
|
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
|
||||||
|
|
||||||
# 2. Verify archive checksum
|
# 2. Verify the checksums manifest signature (Ed25519)
|
||||||
|
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
|
||||||
|
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
|
||||||
|
|
||||||
|
# 3. Verify archive checksum from the signed manifest
|
||||||
echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c
|
echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c
|
||||||
|
|
||||||
# 3. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
|
# 4. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
|
||||||
unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
|
unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
|
|
||||||
All files include SHA256 checksums in `checksums.json`:
|
`checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key.
|
||||||
|
Verify the signature first, then trust hashes from `checksums.json`:
|
||||||
```bash
|
```bash
|
||||||
curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json | jq .
|
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
|
||||||
|
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
|
||||||
|
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
|
||||||
|
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
|
||||||
|
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Migration Plan: Unsigned Feed → Signed Feed
|
||||||
|
|
||||||
|
## 1) Objective
|
||||||
|
|
||||||
|
Move ClawSec advisory distribution from unsigned `feed.json` delivery to detached-signature verification with minimal disruption.
|
||||||
|
|
||||||
|
This plan is written against the current repository behavior:
|
||||||
|
- feed is produced by `poll-nvd-cves.yml` and `community-advisory.yml`
|
||||||
|
- feed is published by `deploy-pages.yml`
|
||||||
|
- suite consumers currently load unsigned JSON from remote/local fallback paths
|
||||||
|
|
||||||
|
## 2) Baseline (today)
|
||||||
|
|
||||||
|
Current feed paths in active use:
|
||||||
|
- Source of truth: `advisories/feed.json`
|
||||||
|
- Skill copy: `skills/clawsec-feed/advisories/feed.json`
|
||||||
|
- Pages copy: `public/advisories/feed.json`
|
||||||
|
- Latest mirror copy: `public/releases/latest/download/advisories/feed.json`
|
||||||
|
|
||||||
|
Current consumer defaults:
|
||||||
|
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||||
|
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||||
|
- default URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||||
|
|
||||||
|
## 3) Migration principles
|
||||||
|
|
||||||
|
- **Dual-publish first**: publish signatures before enforcing verification.
|
||||||
|
- **Fail-open only during transition**: temporary compatibility period is explicit and time-bounded.
|
||||||
|
- **Measured rollout**: enforce verification after telemetry confirms stable signed publishing.
|
||||||
|
- **Fast rollback**: preserve a path back to unsigned behavior while root cause is investigated.
|
||||||
|
|
||||||
|
## 4) Phased timeline
|
||||||
|
|
||||||
|
### Phase 0 — Preparation (Week 0)
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
- signing keys generated and fingerprints recorded
|
||||||
|
- GitHub secrets created
|
||||||
|
- public key(s) added in repo
|
||||||
|
- runbooks approved (`SECURITY-SIGNING.md`, this file)
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- key fingerprints verified by reviewer
|
||||||
|
- protected branch/workflow controls enabled
|
||||||
|
|
||||||
|
### Phase 1 — CI signing enabled, no client enforcement (Week 1)
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
- add feed signing step/workflow to produce `advisories/feed.json.sig`
|
||||||
|
- optionally produce `advisories/checksums.json` + `.sig`
|
||||||
|
- ensure CI verifies signatures before publishing artifacts
|
||||||
|
|
||||||
|
Also update deployment:
|
||||||
|
- copy `.sig` artifacts to `public/advisories/`
|
||||||
|
- mirror `.sig` in `public/releases/latest/download/advisories/`
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- signatures generated successfully for all feed update paths
|
||||||
|
- deploy artifacts contain both payload and signature companions
|
||||||
|
|
||||||
|
### Phase 2 — Consumer dual-read/dual-verify support (Week 2)
|
||||||
|
|
||||||
|
Implement in consumers:
|
||||||
|
- read `feed.json` and `feed.json.sig`
|
||||||
|
- verify with pinned public key
|
||||||
|
- keep controlled temporary unsigned fallback during migration window
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- test remote signed path
|
||||||
|
- test local signed fallback path
|
||||||
|
- test invalid signature rejection
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- verification logic released and tested
|
||||||
|
- no false-positive verification failures in soak period
|
||||||
|
|
||||||
|
### Phase 3 — Enforcement (Week 3)
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- disable temporary unsigned fallback behavior in default paths
|
||||||
|
- add CI/publish gates that fail when `.sig` is missing
|
||||||
|
- announce enforcement date in release notes and docs
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
- all production clients verify signatures by default
|
||||||
|
- no unsigned feed dependency in standard installation flow
|
||||||
|
|
||||||
|
### Phase 4 — Stabilization (Week 4)
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- run first key rotation tabletop drill
|
||||||
|
- run rollback tabletop drill
|
||||||
|
- close migration with post-implementation review
|
||||||
|
|
||||||
|
## 5) Rollback plan
|
||||||
|
|
||||||
|
### Rollback triggers
|
||||||
|
|
||||||
|
Initiate rollback if any of the following occur:
|
||||||
|
- sustained signature verification failures across clients
|
||||||
|
- signing workflow cannot produce valid signatures
|
||||||
|
- key compromise suspected but replacement key is not yet deployed
|
||||||
|
- deployment path publishes mismatched payload/signature pairs
|
||||||
|
|
||||||
|
### Rollback levels
|
||||||
|
|
||||||
|
### Level 1 (preferred): Verification bypass window, keep signed publishing
|
||||||
|
|
||||||
|
Use when: signing is healthy, client-side verifier has a defect.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
1. Re-enable temporary unsigned-acceptance behavior in client release branch.
|
||||||
|
2. Ship patch release with explicit expiry date for bypass.
|
||||||
|
3. Keep signing pipeline active to avoid authenticity gap.
|
||||||
|
|
||||||
|
Recovery target: restore strict verification within 24–48h.
|
||||||
|
|
||||||
|
### Level 2: Signed pipeline paused, unsigned feed temporarily authoritative
|
||||||
|
|
||||||
|
Use when: signing pipeline is unstable or producing inconsistent artifacts.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
1. Disable signing workflow or signing step.
|
||||||
|
2. Continue publishing unsigned `advisories/feed.json` via existing workflows.
|
||||||
|
3. Revert deploy gates that require `.sig` artifacts.
|
||||||
|
4. Open incident record and track time in unsigned mode.
|
||||||
|
|
||||||
|
Recovery target: restore signed publishing ASAP, ideally <72h.
|
||||||
|
|
||||||
|
### Level 3: Full release freeze
|
||||||
|
|
||||||
|
Use when: compromise or integrity of repository/workflows is in doubt.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
1. Pause feed mutation and deployment workflows.
|
||||||
|
2. Restore known-good commit for advisory files/workflows.
|
||||||
|
3. Rotate keys and credentials.
|
||||||
|
4. Resume pipeline only after security review sign-off.
|
||||||
|
|
||||||
|
### Roll-forward after rollback
|
||||||
|
|
||||||
|
- identify root cause
|
||||||
|
- add regression tests/gates
|
||||||
|
- redeploy signed artifacts
|
||||||
|
- publish incident + remediation summary
|
||||||
|
|
||||||
|
## 6) Communication plan
|
||||||
|
|
||||||
|
For enforcement and rollback events, communicate:
|
||||||
|
- what changed
|
||||||
|
- expected operator/client action
|
||||||
|
- duration of temporary compatibility mode (if any)
|
||||||
|
- verification commands for users
|
||||||
|
|
||||||
|
Recommended channels:
|
||||||
|
- GitHub release notes
|
||||||
|
- repository README/docs updates
|
||||||
|
- issue/incident report in repository
|
||||||
|
|
||||||
|
## 7) Go/No-Go checklist
|
||||||
|
|
||||||
|
Go only if all are true:
|
||||||
|
- signing workflow success rate is stable
|
||||||
|
- signatures are mirrored to all documented feed endpoints
|
||||||
|
- consumer verification path tested for remote + local fallback
|
||||||
|
- rollback owner is assigned and reachable
|
||||||
|
- key rotation procedure has been dry-run at least once
|
||||||
@@ -6,10 +6,6 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<p><strong>We are featured on Product Hunt - upvote us and help us spread the word.</strong></p>
|
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/clawsec-by-prompt-security?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-clawsec-by-prompt-security-2" target="_blank" rel="noopener noreferrer"><img alt="ClawSec by Prompt Security - A Security Skill Suite for OpenClaw Agents | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1076044&theme=light&t=1770632815547"></a>
|
|
||||||
|
|
||||||
## Secure Your OpenClaw Bots with a Complete Security Skill Suite
|
## Secure Your OpenClaw Bots with a Complete Security Skill Suite
|
||||||
|
|
||||||
<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4>
|
<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4>
|
||||||
@@ -29,7 +25,7 @@
|
|||||||
[](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
|
[](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
|
||||||
[](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
|
[](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
|
||||||
[](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
|
[](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
|
||||||
[](https://github.com/prompt-security/clawsec/actions/workflows/skill-release.yml)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -202,6 +198,12 @@ Each skill release includes:
|
|||||||
- `SKILL.md` - Main skill documentation
|
- `SKILL.md` - Main skill documentation
|
||||||
- Additional files from SBOM (scripts, configs, etc.)
|
- Additional files from SBOM (scripts, configs, etc.)
|
||||||
|
|
||||||
|
### Signing Operations Documentation
|
||||||
|
|
||||||
|
For feed/release signing rollout and operations guidance:
|
||||||
|
- [`SECURITY-SIGNING.md`](SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
|
||||||
|
- [`MIGRATION-SIGNED-FEED.md`](MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Offline Tools
|
## 🛠️ Offline Tools
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# ClawSec Signing Operations Runbook
|
||||||
|
|
||||||
|
## 1) Purpose
|
||||||
|
|
||||||
|
This runbook defines operational procedures for introducing and running cryptographic signing in the ClawSec repository.
|
||||||
|
|
||||||
|
It covers:
|
||||||
|
- key generation
|
||||||
|
- GitHub secret management
|
||||||
|
- signing workflow integration
|
||||||
|
- key rotation and revocation
|
||||||
|
- incident response
|
||||||
|
|
||||||
|
## 2) Current branch reality (important)
|
||||||
|
|
||||||
|
As of branch `integration/signing-work`, advisory distribution is **unsigned**:
|
||||||
|
|
||||||
|
- Feed writers:
|
||||||
|
- `.github/workflows/poll-nvd-cves.yml` writes `advisories/feed.json` and `skills/clawsec-feed/advisories/feed.json`
|
||||||
|
- `.github/workflows/community-advisory.yml` writes the same files
|
||||||
|
- Feed publish path:
|
||||||
|
- `.github/workflows/deploy-pages.yml` copies to `public/advisories/feed.json`
|
||||||
|
- also mirrors to `public/releases/latest/download/advisories/feed.json`
|
||||||
|
- Feed consumers:
|
||||||
|
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||||
|
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||||
|
- both default to `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||||
|
|
||||||
|
This document defines the **target operating model** for signed artifacts while preserving compatibility during migration.
|
||||||
|
|
||||||
|
## 3) Target signed artifacts
|
||||||
|
|
||||||
|
### Advisory feed channel
|
||||||
|
- `advisories/feed.json` (payload)
|
||||||
|
- `advisories/feed.json.sig` (detached Ed25519 signature; base64)
|
||||||
|
- `advisories/feed-signing-public.pem` (pinned public key)
|
||||||
|
|
||||||
|
### Release artifact channel (recommended)
|
||||||
|
- `<release>/checksums.json`
|
||||||
|
- `<release>/checksums.json.sig`
|
||||||
|
- `advisories/release-signing-public.pem` (or equivalent repo-pinned location)
|
||||||
|
|
||||||
|
## 4) Key roles and custody
|
||||||
|
|
||||||
|
- **Security owner**: approves key lifecycle changes and incident actions.
|
||||||
|
- **Platform owner**: maintains workflows and GitHub secrets.
|
||||||
|
- **Reviewer**: validates fingerprints in PRs/releases.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
- private keys are never committed
|
||||||
|
- public keys are committed and code-reviewed
|
||||||
|
- key generation occurs on trusted operator workstation or HSM-backed environment
|
||||||
|
|
||||||
|
## 5) Key generation (Ed25519)
|
||||||
|
|
||||||
|
> Run from a secure workstation. Do not run on shared CI runners.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Feed signing keypair
|
||||||
|
openssl genpkey -algorithm Ed25519 -out feed-signing-private.pem
|
||||||
|
openssl pkey -in feed-signing-private.pem -pubout -out feed-signing-public.pem
|
||||||
|
|
||||||
|
# Release checksums signing keypair (optional separate key)
|
||||||
|
openssl genpkey -algorithm Ed25519 -out release-signing-private.pem
|
||||||
|
openssl pkey -in release-signing-private.pem -pubout -out release-signing-public.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate fingerprints (store in ticket/change record):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl pkey -pubin -in feed-signing-public.pem -outform DER | shasum -a 256
|
||||||
|
openssl pkey -pubin -in release-signing-public.pem -outform DER | shasum -a 256
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional test-sign before publishing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo '{"probe":"ok"}' > /tmp/probe.json
|
||||||
|
openssl pkeyutl -sign -rawin -inkey feed-signing-private.pem -in /tmp/probe.json -out /tmp/probe.sig.bin
|
||||||
|
openssl base64 -A -in /tmp/probe.sig.bin -out /tmp/probe.sig
|
||||||
|
openssl base64 -d -A -in /tmp/probe.sig -out /tmp/probe.sig.bin
|
||||||
|
openssl pkeyutl -verify -rawin -pubin -inkey feed-signing-public.pem -in /tmp/probe.json -sigfile /tmp/probe.sig.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6) GitHub secrets setup
|
||||||
|
|
||||||
|
### Required secrets
|
||||||
|
|
||||||
|
- `CLAWSEC_SIGNING_PRIVATE_KEY` — PEM-encoded Ed25519 private key (used for both feed and release signing)
|
||||||
|
- `CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE` — (optional) passphrase if the private key is encrypted
|
||||||
|
|
||||||
|
### Procedure
|
||||||
|
|
||||||
|
1. Go to **Repo Settings → Secrets and variables → Actions → New repository secret**.
|
||||||
|
2. Paste full PEM including header/footer.
|
||||||
|
3. Prefer GitHub **Environment secrets** (with required reviewers) for workflow scoping when possible.
|
||||||
|
4. Record change ticket with:
|
||||||
|
- secret name
|
||||||
|
- creator
|
||||||
|
- creation time
|
||||||
|
- key fingerprint
|
||||||
|
|
||||||
|
### Recommended environment protections
|
||||||
|
|
||||||
|
- Require manual approval for workflows that can use signing secrets.
|
||||||
|
- Restrict who can edit protected workflows.
|
||||||
|
- Enable branch protection for `main` and require review for workflow changes.
|
||||||
|
|
||||||
|
## 7) Workflow integration points
|
||||||
|
|
||||||
|
This repo already has feed mutation and deployment workflows. Signing should be inserted as a post-mutation, pre-publish control.
|
||||||
|
|
||||||
|
### Feed pipeline
|
||||||
|
|
||||||
|
Current feed mutation points:
|
||||||
|
- `.github/workflows/poll-nvd-cves.yml`
|
||||||
|
- `.github/workflows/community-advisory.yml`
|
||||||
|
|
||||||
|
Target addition:
|
||||||
|
- add signing step/workflow that:
|
||||||
|
1. regenerates deterministic feed checksums manifest (optional but recommended)
|
||||||
|
2. signs `advisories/feed.json` into `advisories/feed.json.sig`
|
||||||
|
3. verifies signature in CI before commit/publish
|
||||||
|
|
||||||
|
### Pages pipeline
|
||||||
|
|
||||||
|
Current publisher:
|
||||||
|
- `.github/workflows/deploy-pages.yml`
|
||||||
|
|
||||||
|
Target update:
|
||||||
|
- copy `.sig` files to `public/advisories/` and `public/releases/latest/download/advisories/`
|
||||||
|
- fail deploy if expected signed companions are missing after migration enforcement date
|
||||||
|
|
||||||
|
### Skill release pipeline (recommended hardening)
|
||||||
|
|
||||||
|
Current release generator:
|
||||||
|
- `.github/workflows/skill-release.yml` creates `checksums.json`
|
||||||
|
|
||||||
|
Target update:
|
||||||
|
- sign `checksums.json` before `softprops/action-gh-release`
|
||||||
|
- attach `checksums.json.sig` to each release
|
||||||
|
|
||||||
|
## 8) Rotation policy and runbook
|
||||||
|
|
||||||
|
### Rotation cadence
|
||||||
|
- Routine: every 90 days (or stricter org policy).
|
||||||
|
- Immediate: on suspected exposure, unauthorized workflow change, or unexplained signature mismatch.
|
||||||
|
|
||||||
|
### Routine rotation steps
|
||||||
|
|
||||||
|
1. Generate new keypair(s).
|
||||||
|
2. Open PR that updates public key file(s) and fingerprints documentation.
|
||||||
|
3. Add new private key(s) as GitHub secret(s).
|
||||||
|
4. Merge workflow changes that use new key(s).
|
||||||
|
5. Re-sign latest feed/release manifests.
|
||||||
|
6. Validate verification in CI and in one external client.
|
||||||
|
7. Remove old private key secret(s).
|
||||||
|
8. Keep old public key reference only as long as required for historical verification.
|
||||||
|
|
||||||
|
### Revocation steps
|
||||||
|
|
||||||
|
1. Disable workflows using compromised key.
|
||||||
|
2. Remove compromised GitHub secret(s).
|
||||||
|
3. Commit revocation note and new public key.
|
||||||
|
4. Re-sign latest artifacts with replacement key.
|
||||||
|
5. Publish incident advisory with timestamp and impacted window.
|
||||||
|
|
||||||
|
## 9) Incident response playbook (signing-specific)
|
||||||
|
|
||||||
|
### Triggers
|
||||||
|
- signature verification fails for newly published feed/release
|
||||||
|
- unknown commits/workflow edits touching signing paths
|
||||||
|
- leaked key material, accidental logging, or suspicious secret access
|
||||||
|
|
||||||
|
### Severity guide
|
||||||
|
- **SEV-1**: key exfiltration confirmed or maliciously signed payload published
|
||||||
|
- **SEV-2**: verification failures with unknown cause
|
||||||
|
- **SEV-3**: procedural non-compliance, no active compromise
|
||||||
|
|
||||||
|
### Response phases
|
||||||
|
|
||||||
|
1. **Containment**
|
||||||
|
- pause signing/publish workflows
|
||||||
|
- block further feed merges if authenticity is uncertain
|
||||||
|
2. **Investigation**
|
||||||
|
- review workflow run logs
|
||||||
|
- review commits affecting `.github/workflows/`, `advisories/`, and key files
|
||||||
|
- determine first-bad timestamp and affected artifacts
|
||||||
|
3. **Eradication**
|
||||||
|
- rotate/revoke compromised key(s)
|
||||||
|
- restore trusted artifacts from known-good commit
|
||||||
|
4. **Recovery**
|
||||||
|
- re-sign artifacts
|
||||||
|
- redeploy pages/releases
|
||||||
|
- verify via independent client check
|
||||||
|
5. **Post-incident**
|
||||||
|
- publish timeline and remediation summary
|
||||||
|
- tighten controls (review gates, protected environments, secret scope)
|
||||||
|
|
||||||
|
## 10) Audit evidence checklist
|
||||||
|
|
||||||
|
For each release cycle or feed-signing run, retain:
|
||||||
|
- workflow run URL and commit SHA
|
||||||
|
- signer key fingerprint in use
|
||||||
|
- verification result logs
|
||||||
|
- operator/reviewer approvals
|
||||||
|
- any exception or bypass rationale
|
||||||
|
|
||||||
|
## 11) Minimum acceptance criteria before enforcement
|
||||||
|
|
||||||
|
Before requiring signatures in all clients:
|
||||||
|
- signed artifacts are produced consistently for at least 2 weeks
|
||||||
|
- deploy pipeline mirrors signature companions
|
||||||
|
- one rollback drill and one key rotation drill completed successfully
|
||||||
|
- incident response on-call owner identified and documented
|
||||||
@@ -220,24 +220,6 @@ export const Home: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="text-center mb-12">
|
|
||||||
<p className="text-sm text-gray-400 mb-3">
|
|
||||||
We are featured on Product Hunt - upvote us and help us spread the word.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://www.producthunt.com/products/clawsec-by-prompt-security?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-clawsec-by-prompt-security-2"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-block"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="ClawSec by Prompt Security - A Security Skill Suite for OpenClaw Agents | Product Hunt"
|
|
||||||
width="250"
|
|
||||||
height="54"
|
|
||||||
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1076044&theme=light&t=1770632815547"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the ClawSec Suite will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.0.10] - 2026-02-11
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
#### Transport Security Hardening
|
||||||
|
- **TLS Version Enforcement**: Eliminated support for TLS 1.0 and TLS 1.1, enforcing minimum TLS 1.2 for all HTTPS connections
|
||||||
|
- **Certificate Validation**: Enabled strict certificate validation (`rejectUnauthorized: true`) to prevent MITM attacks
|
||||||
|
- **Domain Allowlist**: Restricted advisory feed connections to approved domains only:
|
||||||
|
- `clawsec.prompt.security` (official ClawSec feed host)
|
||||||
|
- `prompt.security` (parent domain)
|
||||||
|
- `raw.githubusercontent.com` (GitHub raw content)
|
||||||
|
- `github.com` (GitHub releases)
|
||||||
|
- **Strong Cipher Suites**: Configured modern cipher suites (AES-GCM, ChaCha20-Poly1305) for secure connections
|
||||||
|
|
||||||
|
#### Signature Verification & Checksum Validation
|
||||||
|
- **Fixed unverified file publication**: Refactored `deploy-pages.yml` workflow to download release assets to temporary directory before signature verification, ensuring unverified files never reach public directory
|
||||||
|
- **Fixed schema mismatch**: Updated `deploy-pages.yml` to generate `checksums.json` with proper `schema_version` and `algorithm` fields that match parser expectations
|
||||||
|
- **Fixed missing checksums abort**: Updated `loadRemoteFeed` to gracefully skip checksum verification when `checksums.json` is missing (e.g., GitHub raw content), while still enforcing fail-closed signature verification
|
||||||
|
- **Fixed parser strictness**: Enhanced `parseChecksumsManifest` to accept legacy manifest formats through a fallback chain:
|
||||||
|
1. `schema_version` (new standard)
|
||||||
|
2. `version` (skill-release.yml format)
|
||||||
|
3. `generated_at` (old deploy-pages.yml format)
|
||||||
|
4. `"1"` (ultimate fallback)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Advisory feed loader now uses `secureFetch` wrapper with TLS 1.2+ enforcement and domain validation
|
||||||
|
- Checksum verification is now graceful: feeds load successfully from sources without checksums (e.g., GitHub raw) while maintaining fail-closed signature verification
|
||||||
|
- Workflow release mirroring flow changed from `download → verify → skip` to `download to temp → verify → mirror` (fail = delete temp)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Unverified skill releases no longer published to public directory on signature verification failure
|
||||||
|
- Schema mismatch between generated and expected checksums manifest fields
|
||||||
|
- Feed loading failures when checksums.json missing from upstream sources
|
||||||
|
- Parser rejection of valid legacy manifest formats
|
||||||
|
|
||||||
|
### Security Impact
|
||||||
|
- **Fail-closed security maintained**: All feed signatures still verified; invalid signatures reject feed loading
|
||||||
|
- **No backward compatibility break**: Legacy manifests continue working through fallback chain
|
||||||
|
- **Enhanced transport security**: Connections protected against downgrade attacks and MITM
|
||||||
|
- **Defense in depth**: Multiple layers of verification (domain, TLS, certificate, signature, checksum)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Notes Template
|
||||||
|
|
||||||
|
When creating a new release, copy this template to the GitHub release notes:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Security Improvements
|
||||||
|
|
||||||
|
### Transport Security
|
||||||
|
✅ TLS 1.2+ enforcement (eliminated TLS 1.0, 1.1)
|
||||||
|
✅ Strict certificate validation
|
||||||
|
✅ Domain allowlist (prompt.security, github.com only)
|
||||||
|
✅ Modern cipher suites (AES-GCM, ChaCha20-Poly1305)
|
||||||
|
|
||||||
|
### Signature & Checksum Verification
|
||||||
|
✅ Unverified files never published (temp directory workflow)
|
||||||
|
✅ Proper schema fields in generated checksums.json
|
||||||
|
✅ Graceful fallback when checksums missing (GitHub raw)
|
||||||
|
✅ Legacy manifest format support (backward compatible)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
All verification tests passed:
|
||||||
|
- ✅ Unit tests: 14/14 passed
|
||||||
|
- ✅ Parser lenience: 3/3 legacy formats accepted
|
||||||
|
- ✅ Remote loading: Gracefully handles missing checksums
|
||||||
|
- ✅ Workflow security: Temp directory prevents unverified publication
|
||||||
|
```
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: clawsec-suite
|
name: clawsec-suite
|
||||||
version: 0.0.9
|
version: 0.0.10
|
||||||
description: ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.
|
description: ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.
|
||||||
homepage: https://clawsec.prompt.security
|
homepage: https://clawsec.prompt.security
|
||||||
clawdis:
|
clawdis:
|
||||||
emoji: "📦"
|
emoji: "📦"
|
||||||
requires:
|
requires:
|
||||||
bins: [curl, jq, shasum]
|
bins: [curl, jq, shasum, openssl]
|
||||||
---
|
---
|
||||||
|
|
||||||
# ClawSec Suite
|
# ClawSec Suite
|
||||||
@@ -41,7 +41,7 @@ This means `clawsec-suite` can:
|
|||||||
npx clawhub@latest install clawsec-suite
|
npx clawhub@latest install clawsec-suite
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option B: Manual download with verification
|
### Option B: Manual download with signature + checksum verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -56,14 +56,45 @@ DOWNLOAD_DIR="$TEMP_DIR/downloads"
|
|||||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||||
mkdir -p "$DOWNLOAD_DIR"
|
mkdir -p "$DOWNLOAD_DIR"
|
||||||
|
|
||||||
# 1) Download checksums manifest
|
# Pinned release-signing public key (verify fingerprint out-of-band on first use)
|
||||||
|
# Fingerprint (SHA-256 of SPKI DER): 35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854
|
||||||
|
RELEASE_PUBKEY_SHA256="35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854"
|
||||||
|
cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM'
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAtaRGONGp0Syl9EBS17hEYgGTwUtfZgigklS6vAe5MlQ=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
PEM
|
||||||
|
|
||||||
|
ACTUAL_KEY_SHA256="$(openssl pkey -pubin -in "$TEMP_DIR/release-signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
|
||||||
|
if [ "$ACTUAL_KEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
|
||||||
|
echo "ERROR: Release public key fingerprint mismatch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) Download checksums manifest + detached signature
|
||||||
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
|
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
|
||||||
|
curl -fsSL "$BASE/checksums.json.sig" -o "$TEMP_DIR/checksums.json.sig"
|
||||||
|
|
||||||
|
# 2) Verify checksums manifest signature before trusting any file URLs or hashes
|
||||||
|
openssl base64 -d -A -in "$TEMP_DIR/checksums.json.sig" -out "$TEMP_DIR/checksums.json.sig.bin"
|
||||||
|
if ! openssl pkeyutl -verify \
|
||||||
|
-pubin \
|
||||||
|
-inkey "$TEMP_DIR/release-signing-public.pem" \
|
||||||
|
-sigfile "$TEMP_DIR/checksums.json.sig.bin" \
|
||||||
|
-rawin \
|
||||||
|
-in "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: checksums.json signature verification failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||||
echo "ERROR: Invalid checksums.json format" >&2
|
echo "ERROR: Invalid checksums.json format" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2) Download every file listed in checksums and verify immediately
|
echo "Checksums manifest signature verified."
|
||||||
|
|
||||||
|
# 3) Download every file listed in checksums and verify immediately
|
||||||
DOWNLOAD_FAILED=0
|
DOWNLOAD_FAILED=0
|
||||||
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
|
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
|
||||||
FILE_URL="$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")"
|
FILE_URL="$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")"
|
||||||
@@ -94,7 +125,7 @@ if [ "$DOWNLOAD_FAILED" -eq 1 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3) Install files using paths from checksums.json
|
# 4) Install files using paths from checksums.json
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[ -z "$file" ] && continue
|
[ -z "$file" ] && continue
|
||||||
REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")"
|
REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")"
|
||||||
@@ -109,7 +140,7 @@ chmod 600 "$DEST/skill.json"
|
|||||||
find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||||
|
|
||||||
echo "Installed clawsec-suite v${VERSION} to: $DEST"
|
echo "Installed clawsec-suite v${VERSION} to: $DEST"
|
||||||
echo "Next step (OpenClaw): node \"$DEST/scripts/setup_advisory_hook.mjs\""
|
echo "Next step (OpenClaw): node \"\$DEST/scripts/setup_advisory_hook.mjs\""
|
||||||
```
|
```
|
||||||
|
|
||||||
## OpenClaw Automation (Hook + Optional Cron)
|
## OpenClaw Automation (Hook + Optional Cron)
|
||||||
@@ -147,6 +178,7 @@ node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --versio
|
|||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
- If no advisory match is found, install proceeds.
|
- If no advisory match is found, install proceeds.
|
||||||
|
- If `--version` is omitted, matching is conservative: any advisory that references the skill name is treated as a match.
|
||||||
- If advisory match is found, the script prints advisory context and exits with code `42`.
|
- If advisory match is found, the script prints advisory context and exits with code `42`.
|
||||||
- Then require an explicit second confirmation from the user and rerun with `--confirm-advisory`:
|
- Then require an explicit second confirmation from the user and rerun with `--confirm-advisory`:
|
||||||
|
|
||||||
@@ -163,10 +195,17 @@ This enforces:
|
|||||||
The embedded feed logic uses these defaults:
|
The embedded feed logic uses these defaults:
|
||||||
|
|
||||||
- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||||
|
- Remote feed signature URL: `${CLAWSEC_FEED_URL}.sig` (override with `CLAWSEC_FEED_SIG_URL`)
|
||||||
|
- Remote checksums manifest URL: sibling `checksums.json` (override with `CLAWSEC_FEED_CHECKSUMS_URL`)
|
||||||
- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json`
|
- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json`
|
||||||
|
- Local feed signature: `${CLAWSEC_LOCAL_FEED}.sig` (override with `CLAWSEC_LOCAL_FEED_SIG`)
|
||||||
|
- Local checksums manifest: `~/.openclaw/skills/clawsec-suite/advisories/checksums.json`
|
||||||
|
- Pinned feed signing key: `~/.openclaw/skills/clawsec-suite/advisories/feed-signing-public.pem` (override with `CLAWSEC_FEED_PUBLIC_KEY`)
|
||||||
- State file: `~/.openclaw/clawsec-suite-feed-state.json`
|
- State file: `~/.openclaw/clawsec-suite-feed-state.json`
|
||||||
- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`)
|
- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`)
|
||||||
|
|
||||||
|
**Fail-closed verification:** Both signature and checksum manifest verification are required by default. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream.
|
||||||
|
|
||||||
### Quick feed check
|
### Quick feed check
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -245,7 +284,9 @@ npx clawhub@latest install clawtributor
|
|||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
- Always verify checksums before installing files manually.
|
- Always verify `checksums.json` signature before trusting its file URLs/hashes, then verify each file checksum.
|
||||||
|
- Verify advisory feed detached signatures; do not enable `CLAWSEC_ALLOW_UNSIGNED_FEED` outside temporary migration windows.
|
||||||
- Keep advisory polling rate-limited (at least 5 minutes between checks).
|
- Keep advisory polling rate-limited (at least 5 minutes between checks).
|
||||||
- Treat `critical` and `high` advisories affecting installed skills as immediate action items.
|
- Treat `critical` and `high` advisories affecting installed skills as immediate action items.
|
||||||
- If you migrate off standalone `clawsec-feed`, keep one canonical state file to avoid duplicate notifications.
|
- If you migrate off standalone `clawsec-feed`, keep one canonical state file to avoid duplicate notifications.
|
||||||
|
- Pin and verify public key fingerprints out-of-band before first use.
|
||||||
|
|||||||
@@ -24,7 +24,16 @@ and asks for user approval first.
|
|||||||
## Optional Environment Variables
|
## Optional Environment Variables
|
||||||
|
|
||||||
- `CLAWSEC_FEED_URL`: override remote feed URL.
|
- `CLAWSEC_FEED_URL`: override remote feed URL.
|
||||||
|
- `CLAWSEC_FEED_SIG_URL`: override detached remote feed signature URL (default `${CLAWSEC_FEED_URL}.sig`).
|
||||||
|
- `CLAWSEC_FEED_CHECKSUMS_URL`: override remote checksum manifest URL (default sibling `checksums.json`).
|
||||||
|
- `CLAWSEC_FEED_CHECKSUMS_SIG_URL`: override detached remote checksum manifest signature URL.
|
||||||
|
- `CLAWSEC_FEED_PUBLIC_KEY`: path to pinned feed-signing public key PEM.
|
||||||
- `CLAWSEC_LOCAL_FEED`: override local fallback feed file.
|
- `CLAWSEC_LOCAL_FEED`: override local fallback feed file.
|
||||||
|
- `CLAWSEC_LOCAL_FEED_SIG`: override local detached feed signature path.
|
||||||
|
- `CLAWSEC_LOCAL_FEED_CHECKSUMS`: override local checksum manifest path.
|
||||||
|
- `CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG`: override local checksum manifest signature path.
|
||||||
|
- `CLAWSEC_VERIFY_CHECKSUM_MANIFEST`: set to `0` only for emergency troubleshooting (default verifies checksums).
|
||||||
|
- `CLAWSEC_ALLOW_UNSIGNED_FEED`: set to `1` only for temporary migration compatibility; bypasses signature/checksum verification.
|
||||||
- `CLAWSEC_SUITE_STATE_FILE`: override state file path.
|
- `CLAWSEC_SUITE_STATE_FILE`: override state file path.
|
||||||
- `CLAWSEC_INSTALL_ROOT`: override installed skills root.
|
- `CLAWSEC_INSTALL_ROOT`: override installed skills root.
|
||||||
- `CLAWSEC_SUITE_DIR`: override clawsec-suite install path.
|
- `CLAWSEC_SUITE_DIR`: override clawsec-suite install path.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { uniqueStrings } from "./lib/utils.mjs";
|
import { uniqueStrings } from "./lib/utils.mjs";
|
||||||
import { isValidFeedPayload, loadRemoteFeed } from "./lib/feed.mjs";
|
import { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.mjs";
|
||||||
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
|
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
|
||||||
import { loadState, persistState } from "./lib/state.ts";
|
import { loadState, persistState } from "./lib/state.ts";
|
||||||
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
|
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
|
||||||
@@ -10,6 +10,7 @@ import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } fro
|
|||||||
const DEFAULT_FEED_URL =
|
const DEFAULT_FEED_URL =
|
||||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||||
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
||||||
|
let unsignedModeWarningShown = false;
|
||||||
|
|
||||||
function expandHome(inputPath: string): string {
|
function expandHome(inputPath: string): string {
|
||||||
if (!inputPath) return inputPath;
|
if (!inputPath) return inputPath;
|
||||||
@@ -49,16 +50,42 @@ function scannedRecently(lastScan: string | null, minIntervalSeconds: number): b
|
|||||||
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
|
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFeed(feedUrl: string, localFeedPath: string): Promise<FeedPayload> {
|
async function loadFeed(options: {
|
||||||
const remoteFeed = await loadRemoteFeed(feedUrl);
|
feedUrl: string;
|
||||||
|
feedSignatureUrl: string;
|
||||||
|
feedChecksumsUrl: string;
|
||||||
|
feedChecksumsSignatureUrl: string;
|
||||||
|
localFeedPath: string;
|
||||||
|
localFeedSignaturePath: string;
|
||||||
|
localFeedChecksumsPath: string;
|
||||||
|
localFeedChecksumsSignaturePath: string;
|
||||||
|
feedPublicKeyPath: string;
|
||||||
|
allowUnsigned: boolean;
|
||||||
|
verifyChecksumManifest: boolean;
|
||||||
|
}): Promise<FeedPayload> {
|
||||||
|
const publicKeyPem = options.allowUnsigned ? "" : await fs.readFile(options.feedPublicKeyPath, "utf8");
|
||||||
|
|
||||||
|
const remoteFeed = await loadRemoteFeed(options.feedUrl, {
|
||||||
|
signatureUrl: options.feedSignatureUrl,
|
||||||
|
checksumsUrl: options.feedChecksumsUrl,
|
||||||
|
checksumsSignatureUrl: options.feedChecksumsSignatureUrl,
|
||||||
|
publicKeyPem,
|
||||||
|
checksumsPublicKeyPem: publicKeyPem,
|
||||||
|
allowUnsigned: options.allowUnsigned,
|
||||||
|
verifyChecksumManifest: options.verifyChecksumManifest,
|
||||||
|
});
|
||||||
if (remoteFeed) return remoteFeed;
|
if (remoteFeed) return remoteFeed;
|
||||||
|
|
||||||
const fallbackRaw = await fs.readFile(localFeedPath, "utf8");
|
return await loadLocalFeed(options.localFeedPath, {
|
||||||
const fallbackPayload = JSON.parse(fallbackRaw);
|
signaturePath: options.localFeedSignaturePath,
|
||||||
if (!isValidFeedPayload(fallbackPayload)) {
|
checksumsPath: options.localFeedChecksumsPath,
|
||||||
throw new Error(`Invalid advisory feed format in fallback file: ${localFeedPath}`);
|
checksumsSignaturePath: options.localFeedChecksumsSignaturePath,
|
||||||
}
|
publicKeyPem,
|
||||||
return fallbackPayload;
|
checksumsPublicKeyPem: publicKeyPem,
|
||||||
|
allowUnsigned: options.allowUnsigned,
|
||||||
|
verifyChecksumManifest: options.verifyChecksumManifest,
|
||||||
|
checksumPublicKeyEntry: path.basename(options.feedPublicKeyPath),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (event: HookEvent): Promise<void> => {
|
const handler = async (event: HookEvent): Promise<void> => {
|
||||||
@@ -69,15 +96,41 @@ const handler = async (event: HookEvent): Promise<void> => {
|
|||||||
);
|
);
|
||||||
const suiteDir = expandHome(process.env.CLAWSEC_SUITE_DIR || path.join(installRoot, "clawsec-suite"));
|
const suiteDir = expandHome(process.env.CLAWSEC_SUITE_DIR || path.join(installRoot, "clawsec-suite"));
|
||||||
const localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json"));
|
const localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json"));
|
||||||
|
const localFeedSignaturePath = expandHome(
|
||||||
|
process.env.CLAWSEC_LOCAL_FEED_SIG || `${localFeedPath}.sig`,
|
||||||
|
);
|
||||||
|
const localFeedChecksumsPath = expandHome(
|
||||||
|
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || path.join(path.dirname(localFeedPath), "checksums.json"),
|
||||||
|
);
|
||||||
|
const localFeedChecksumsSignaturePath = expandHome(
|
||||||
|
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || `${localFeedChecksumsPath}.sig`,
|
||||||
|
);
|
||||||
|
const feedPublicKeyPath = expandHome(
|
||||||
|
process.env.CLAWSEC_FEED_PUBLIC_KEY || path.join(suiteDir, "advisories", "feed-signing-public.pem"),
|
||||||
|
);
|
||||||
const stateFile = expandHome(
|
const stateFile = expandHome(
|
||||||
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
|
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
|
||||||
);
|
);
|
||||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||||
|
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||||
|
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||||
|
const feedChecksumsSignatureUrl =
|
||||||
|
process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||||
|
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||||
|
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||||
const scanIntervalSeconds = parsePositiveInteger(
|
const scanIntervalSeconds = parsePositiveInteger(
|
||||||
process.env.CLAWSEC_HOOK_INTERVAL_SECONDS,
|
process.env.CLAWSEC_HOOK_INTERVAL_SECONDS,
|
||||||
DEFAULT_SCAN_INTERVAL_SECONDS,
|
DEFAULT_SCAN_INTERVAL_SECONDS,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (allowUnsigned && !unsignedModeWarningShown) {
|
||||||
|
unsignedModeWarningShown = true;
|
||||||
|
console.warn(
|
||||||
|
"[clawsec-advisory-guardian] CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. " +
|
||||||
|
"This bypass is temporary migration compatibility and should be removed as soon as signed feed artifacts are available.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const forceScan = toEventName(event) === "command:new";
|
const forceScan = toEventName(event) === "command:new";
|
||||||
const state = await loadState(stateFile);
|
const state = await loadState(stateFile);
|
||||||
if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
|
if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
|
||||||
@@ -86,7 +139,19 @@ const handler = async (event: HookEvent): Promise<void> => {
|
|||||||
|
|
||||||
let feed: FeedPayload;
|
let feed: FeedPayload;
|
||||||
try {
|
try {
|
||||||
feed = await loadFeed(feedUrl, localFeedPath);
|
feed = await loadFeed({
|
||||||
|
feedUrl,
|
||||||
|
feedSignatureUrl,
|
||||||
|
feedChecksumsUrl,
|
||||||
|
feedChecksumsSignatureUrl,
|
||||||
|
localFeedPath,
|
||||||
|
localFeedSignaturePath,
|
||||||
|
localFeedChecksumsPath,
|
||||||
|
localFeedChecksumsSignaturePath,
|
||||||
|
feedPublicKeyPath,
|
||||||
|
allowUnsigned,
|
||||||
|
verifyChecksumManifest,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`);
|
console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,100 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import https from "node:https";
|
||||||
|
import path from "node:path";
|
||||||
import { isObject } from "./utils.mjs";
|
import { isObject } from "./utils.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed domains for feed/signature fetching.
|
||||||
|
* Only connections to these domains are permitted for security.
|
||||||
|
*/
|
||||||
|
const ALLOWED_DOMAINS = [
|
||||||
|
"clawsec.prompt.security",
|
||||||
|
"prompt.security",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
"github.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for security policy violations.
|
||||||
|
* These errors should always propagate and never be silently caught.
|
||||||
|
*/
|
||||||
|
class SecurityPolicyError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SecurityPolicyError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
|
||||||
|
* @returns {https.Agent}
|
||||||
|
*/
|
||||||
|
function createSecureAgent() {
|
||||||
|
return new https.Agent({
|
||||||
|
// Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1)
|
||||||
|
minVersion: "TLSv1.2",
|
||||||
|
// Ensure certificate validation is enabled (reject unauthorized certificates)
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
// Use strong cipher suites
|
||||||
|
ciphers: "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a URL is from an allowed domain.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isAllowedDomain(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
// Only allow HTTPS protocol
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
|
||||||
|
// Check if hostname matches any allowed domain
|
||||||
|
return ALLOWED_DOMAINS.some(
|
||||||
|
(allowed) =>
|
||||||
|
hostname === allowed || hostname.endsWith(`.${allowed}`)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure wrapper around fetch with TLS enforcement and domain validation.
|
||||||
|
* @param {string} url
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
* @throws {SecurityPolicyError} If URL is not from an allowed domain
|
||||||
|
*/
|
||||||
|
async function secureFetch(url, options = {}) {
|
||||||
|
// Validate domain before making request
|
||||||
|
if (!isAllowedDomain(url)) {
|
||||||
|
throw new SecurityPolicyError(
|
||||||
|
`Security policy violation: URL domain not allowed. ` +
|
||||||
|
`Only connections to ${ALLOWED_DOMAINS.join(", ")} are permitted. ` +
|
||||||
|
`Blocked: ${url}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use secure HTTPS agent with TLS 1.2+ enforcement
|
||||||
|
const agent = createSecureAgent();
|
||||||
|
|
||||||
|
return globalThis.fetch(url, {
|
||||||
|
...options,
|
||||||
|
// Attach secure agent for Node.js fetch
|
||||||
|
// @ts-ignore - agent is supported in Node.js fetch
|
||||||
|
agent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} rawSpecifier
|
* @param {string} rawSpecifier
|
||||||
* @returns {{ name: string; versionSpec: string } | null}
|
* @returns {{ name: string; versionSpec: string } | null}
|
||||||
@@ -25,34 +120,392 @@ export function parseAffectedSpecifier(rawSpecifier) {
|
|||||||
*/
|
*/
|
||||||
export function isValidFeedPayload(raw) {
|
export function isValidFeedPayload(raw) {
|
||||||
if (!isObject(raw)) return false;
|
if (!isObject(raw)) return false;
|
||||||
|
if (typeof raw.version !== "string" || !raw.version.trim()) return false;
|
||||||
if (!Array.isArray(raw.advisories)) return false;
|
if (!Array.isArray(raw.advisories)) return false;
|
||||||
|
|
||||||
|
for (const advisory of raw.advisories) {
|
||||||
|
if (!isObject(advisory)) return false;
|
||||||
|
if (typeof advisory.id !== "string" || !advisory.id.trim()) return false;
|
||||||
|
if (typeof advisory.severity !== "string" || !advisory.severity.trim()) return false;
|
||||||
|
if (!Array.isArray(advisory.affected)) return false;
|
||||||
|
if (!advisory.affected.every((entry) => typeof entry === "string" && entry.trim())) return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} feedUrl
|
* @param {string} signatureRaw
|
||||||
* @returns {Promise<import("./types.ts").FeedPayload | null>}
|
* @returns {Buffer | null}
|
||||||
*/
|
*/
|
||||||
export async function loadRemoteFeed(feedUrl) {
|
function decodeSignature(signatureRaw) {
|
||||||
const fetchFn = /** @type {{ fetch?: Function }} */ (globalThis).fetch;
|
const trimmed = String(signatureRaw ?? "").trim();
|
||||||
if (typeof fetchFn !== "function") return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
let encoded = trimmed;
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (isObject(parsed) && typeof parsed.signature === "string") {
|
||||||
|
encoded = parsed.signature;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = encoded.replace(/\s+/g, "");
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Buffer.from(normalized, "base64");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} payloadRaw
|
||||||
|
* @param {string} signatureRaw
|
||||||
|
* @param {string} publicKeyPem
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
|
||||||
|
const signature = decodeSignature(signatureRaw);
|
||||||
|
if (!signature) return false;
|
||||||
|
|
||||||
|
const keyPem = String(publicKeyPem ?? "").trim();
|
||||||
|
if (!keyPem) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const publicKey = crypto.createPublicKey(keyPem);
|
||||||
|
return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | Buffer} content
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function sha256Hex(content) {
|
||||||
|
return crypto.createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function extractSha256Value(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isObject(value) && typeof value.sha256 === "string") {
|
||||||
|
const normalized = value.sha256.trim().toLowerCase();
|
||||||
|
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} manifestRaw
|
||||||
|
* @returns {{ schemaVersion: string; algorithm: string; files: Record<string, string> }}
|
||||||
|
*/
|
||||||
|
function parseChecksumsManifest(manifestRaw) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(manifestRaw);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Checksum manifest is not valid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObject(parsed)) {
|
||||||
|
throw new Error("Checksum manifest must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const algorithmRaw = typeof parsed.algorithm === "string" ? parsed.algorithm.trim().toLowerCase() : "sha256";
|
||||||
|
if (algorithmRaw !== "sha256") {
|
||||||
|
throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || "(empty)"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support legacy manifest formats:
|
||||||
|
// - New standard: schema_version field
|
||||||
|
// - skill-release.yml: version field (e.g., "0.0.1")
|
||||||
|
// - deploy-pages.yml (pre-fix): generated_at field (e.g., "2026-02-08T...")
|
||||||
|
// - Ultimate fallback: "1"
|
||||||
|
const schemaVersion = (
|
||||||
|
typeof parsed.schema_version === "string" ? parsed.schema_version.trim() :
|
||||||
|
typeof parsed.version === "string" ? parsed.version.trim() :
|
||||||
|
typeof parsed.generated_at === "string" ? parsed.generated_at.trim() :
|
||||||
|
"1"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!schemaVersion) {
|
||||||
|
throw new Error("Checksum manifest missing schema_version");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObject(parsed.files)) {
|
||||||
|
throw new Error("Checksum manifest missing files object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = /** @type {Record<string, string>} */ ({});
|
||||||
|
for (const [key, value] of Object.entries(parsed.files)) {
|
||||||
|
if (!String(key).trim()) continue;
|
||||||
|
const digest = extractSha256Value(value);
|
||||||
|
if (!digest) {
|
||||||
|
throw new Error(`Invalid checksum digest entry for ${key}`);
|
||||||
|
}
|
||||||
|
files[key] = digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(files).length === 0) {
|
||||||
|
throw new Error("Checksum manifest has no usable file digests");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion,
|
||||||
|
algorithm: algorithmRaw,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ files: Record<string, string> }} manifest
|
||||||
|
* @param {Record<string, string | Buffer>} expectedEntries
|
||||||
|
*/
|
||||||
|
function verifyChecksums(manifest, expectedEntries) {
|
||||||
|
for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
|
||||||
|
if (!entryName) continue;
|
||||||
|
|
||||||
|
const expectedDigest = manifest.files[entryName];
|
||||||
|
if (!expectedDigest) {
|
||||||
|
throw new Error(`Checksum manifest missing required entry: ${entryName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualDigest = sha256Hex(entryContent);
|
||||||
|
if (actualDigest !== expectedDigest) {
|
||||||
|
throw new Error(`Checksum mismatch for ${entryName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} feedUrl
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function defaultChecksumsUrl(feedUrl) {
|
||||||
|
try {
|
||||||
|
return new URL("checksums.json", feedUrl).toString();
|
||||||
|
} catch {
|
||||||
|
const fallbackBase = String(feedUrl ?? "").replace(/\/?[^/]*$/, "");
|
||||||
|
return `${fallbackBase}/checksums.json`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts the basename from a URL or file path.
|
||||||
|
* @param {string} urlOrPath
|
||||||
|
* @param {string} fallback
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function safeBasename(urlOrPath, fallback) {
|
||||||
|
try {
|
||||||
|
// Try parsing as URL first
|
||||||
|
const parsed = new URL(urlOrPath);
|
||||||
|
const pathname = parsed.pathname;
|
||||||
|
const lastSlash = pathname.lastIndexOf("/");
|
||||||
|
if (lastSlash >= 0 && lastSlash < pathname.length - 1) {
|
||||||
|
return pathname.slice(lastSlash + 1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a URL, try as path
|
||||||
|
const normalized = String(urlOrPath ?? "").trim();
|
||||||
|
const lastSlash = normalized.lastIndexOf("/");
|
||||||
|
if (lastSlash >= 0 && lastSlash < normalized.length - 1) {
|
||||||
|
return normalized.slice(lastSlash + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Function} fetchFn
|
||||||
|
* @param {string} targetUrl
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
|
async function fetchText(fetchFn, targetUrl) {
|
||||||
const controller = new globalThis.AbortController();
|
const controller = new globalThis.AbortController();
|
||||||
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
|
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchFn(feedUrl, {
|
const response = await fetchFn(targetUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: { accept: "application/json" },
|
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const payload = await response.json();
|
return await response.text();
|
||||||
if (!isValidFeedPayload(payload)) return null;
|
} catch (error) {
|
||||||
return payload;
|
// Re-throw security policy violations - these should never be silently caught
|
||||||
} catch {
|
if (error instanceof SecurityPolicyError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Network errors, timeouts, etc. return null (graceful degradation)
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.clearTimeout(timeout);
|
globalThis.clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} feedPath
|
||||||
|
* @param {{
|
||||||
|
* signaturePath?: string;
|
||||||
|
* checksumsPath?: string;
|
||||||
|
* checksumsSignaturePath?: string;
|
||||||
|
* publicKeyPem?: string;
|
||||||
|
* checksumsPublicKeyPem?: string;
|
||||||
|
* allowUnsigned?: boolean;
|
||||||
|
* verifyChecksumManifest?: boolean;
|
||||||
|
* checksumFeedEntry?: string;
|
||||||
|
* checksumSignatureEntry?: string;
|
||||||
|
* checksumPublicKeyEntry?: string;
|
||||||
|
* }} [options]
|
||||||
|
* @returns {Promise<import("./types.ts").FeedPayload>}
|
||||||
|
*/
|
||||||
|
export async function loadLocalFeed(feedPath, options = {}) {
|
||||||
|
const signaturePath = options.signaturePath ?? `${feedPath}.sig`;
|
||||||
|
const checksumsPath = options.checksumsPath ?? path.join(path.dirname(feedPath), "checksums.json");
|
||||||
|
const checksumsSignaturePath = options.checksumsSignaturePath ?? `${checksumsPath}.sig`;
|
||||||
|
const publicKeyPem = String(options.publicKeyPem ?? "");
|
||||||
|
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
|
||||||
|
const allowUnsigned = options.allowUnsigned === true;
|
||||||
|
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||||
|
|
||||||
|
const payloadRaw = await fs.readFile(feedPath, "utf8");
|
||||||
|
|
||||||
|
if (!allowUnsigned) {
|
||||||
|
const signatureRaw = await fs.readFile(signaturePath, "utf8");
|
||||||
|
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||||
|
throw new Error(`Feed signature verification failed for local feed: ${feedPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyChecksumManifest) {
|
||||||
|
const checksumsRaw = await fs.readFile(checksumsPath, "utf8");
|
||||||
|
const checksumsSignatureRaw = await fs.readFile(checksumsSignaturePath, "utf8");
|
||||||
|
|
||||||
|
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
||||||
|
throw new Error(`Checksum manifest signature verification failed: ${checksumsPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
||||||
|
const checksumFeedEntry = options.checksumFeedEntry ?? path.basename(feedPath);
|
||||||
|
const checksumSignatureEntry = options.checksumSignatureEntry ?? path.basename(signaturePath);
|
||||||
|
const expectedEntries = /** @type {Record<string, string>} */ ({
|
||||||
|
[checksumFeedEntry]: payloadRaw,
|
||||||
|
[checksumSignatureEntry]: signatureRaw,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.checksumPublicKeyEntry) {
|
||||||
|
expectedEntries[options.checksumPublicKeyEntry] = publicKeyPem;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyChecksums(checksumsManifest, expectedEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(payloadRaw);
|
||||||
|
if (!isValidFeedPayload(payload)) {
|
||||||
|
throw new Error(`Invalid advisory feed format: ${feedPath}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} feedUrl
|
||||||
|
* @param {{
|
||||||
|
* signatureUrl?: string;
|
||||||
|
* checksumsUrl?: string;
|
||||||
|
* checksumsSignatureUrl?: string;
|
||||||
|
* publicKeyPem?: string;
|
||||||
|
* checksumsPublicKeyPem?: string;
|
||||||
|
* allowUnsigned?: boolean;
|
||||||
|
* verifyChecksumManifest?: boolean;
|
||||||
|
* checksumFeedEntry?: string;
|
||||||
|
* checksumSignatureEntry?: string;
|
||||||
|
* }} [options]
|
||||||
|
* @returns {Promise<import("./types.ts").FeedPayload | null>}
|
||||||
|
*/
|
||||||
|
export async function loadRemoteFeed(feedUrl, options = {}) {
|
||||||
|
// Use secure fetch with TLS 1.2+ enforcement and domain validation
|
||||||
|
const fetchFn = secureFetch;
|
||||||
|
if (typeof fetchFn !== "function") return null;
|
||||||
|
|
||||||
|
const signatureUrl = options.signatureUrl ?? `${feedUrl}.sig`;
|
||||||
|
const checksumsUrl = options.checksumsUrl ?? defaultChecksumsUrl(feedUrl);
|
||||||
|
const checksumsSignatureUrl = options.checksumsSignatureUrl ?? `${checksumsUrl}.sig`;
|
||||||
|
const publicKeyPem = String(options.publicKeyPem ?? "");
|
||||||
|
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
|
||||||
|
const allowUnsigned = options.allowUnsigned === true;
|
||||||
|
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payloadRaw = await fetchText(fetchFn, feedUrl);
|
||||||
|
if (!payloadRaw) return null;
|
||||||
|
|
||||||
|
if (!allowUnsigned) {
|
||||||
|
const signatureRaw = await fetchText(fetchFn, signatureUrl);
|
||||||
|
if (!signatureRaw) return null;
|
||||||
|
|
||||||
|
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only verify checksums if explicitly requested AND both checksum files are available.
|
||||||
|
// Note: Many upstream workflows (e.g., GitHub raw content) don't publish checksums.json,
|
||||||
|
// so we gracefully skip verification when these files are missing.
|
||||||
|
if (verifyChecksumManifest) {
|
||||||
|
const checksumsRaw = await fetchText(fetchFn, checksumsUrl);
|
||||||
|
const checksumsSignatureRaw = await fetchText(fetchFn, checksumsSignatureUrl);
|
||||||
|
|
||||||
|
// Only proceed if BOTH checksum files are present
|
||||||
|
if (checksumsRaw && checksumsSignatureRaw) {
|
||||||
|
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
||||||
|
return null; // Fail-closed: invalid signature
|
||||||
|
}
|
||||||
|
|
||||||
|
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
||||||
|
// Derive checksum entry names from actual URLs (supports any filename, not just feed.json)
|
||||||
|
const checksumFeedEntry = options.checksumFeedEntry ?? safeBasename(feedUrl, "feed.json");
|
||||||
|
const checksumSignatureEntry = options.checksumSignatureEntry ?? safeBasename(signatureUrl, "feed.json.sig");
|
||||||
|
verifyChecksums(checksumsManifest, {
|
||||||
|
[checksumFeedEntry]: payloadRaw,
|
||||||
|
[checksumSignatureEntry]: signatureRaw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If checksum files missing: continue without checksum verification
|
||||||
|
// (feed signature was already verified above at line 328)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(payloadRaw);
|
||||||
|
if (!isValidFeedPayload(payload)) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Security policy violations (invalid URLs, non-HTTPS, disallowed domains) return null
|
||||||
|
// to allow graceful fallback to local feed
|
||||||
|
if (error instanceof SecurityPolicyError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Re-throw unexpected errors
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type Advisory = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type FeedPayload = {
|
export type FeedPayload = {
|
||||||
|
version: string;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
advisories: Advisory[];
|
advisories: Advisory[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const DEFAULT_FILES = ["feed-signing-public.pem", "feed.json", "feed.json.sig"];
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
process.stderr.write(
|
||||||
|
[
|
||||||
|
"Usage:",
|
||||||
|
" node scripts/generate_checksums_json.mjs --out advisories/checksums.json [--base advisories] [--file feed.json --file feed.json.sig ...]",
|
||||||
|
"",
|
||||||
|
"Defaults:",
|
||||||
|
" --base <dirname(--out)>",
|
||||||
|
` --file ${DEFAULT_FILES.join(" --file ")}`,
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const parsed = { files: [] };
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (token === "--out") {
|
||||||
|
parsed.outPath = argv[++i];
|
||||||
|
} else if (token === "--base") {
|
||||||
|
parsed.baseDir = argv[++i];
|
||||||
|
} else if (token === "--file") {
|
||||||
|
parsed.files.push(argv[++i]);
|
||||||
|
} else if (token === "-h" || token === "--help") {
|
||||||
|
parsed.help = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown argument: ${token}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Hex(buffer) {
|
||||||
|
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { outPath, baseDir, files, help } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (help) {
|
||||||
|
usage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outPath) {
|
||||||
|
usage();
|
||||||
|
throw new Error("Missing required argument: --out");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedBase = path.resolve(baseDir ?? path.dirname(outPath));
|
||||||
|
const fileList = files.length > 0 ? files : DEFAULT_FILES;
|
||||||
|
|
||||||
|
const checksums = {};
|
||||||
|
|
||||||
|
for (const relativePath of [...fileList].sort((a, b) => a.localeCompare(b))) {
|
||||||
|
const absolutePath = path.resolve(resolvedBase, relativePath);
|
||||||
|
const content = await fs.readFile(absolutePath);
|
||||||
|
checksums[relativePath] = sha256Hex(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
schema_version: "1.0",
|
||||||
|
algorithm: "sha256",
|
||||||
|
files: checksums,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeFile(`${outPath}`, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||||
|
process.stdout.write(`Wrote ${outPath}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -6,12 +6,21 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
|
import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
|
||||||
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
|
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
|
||||||
import { parseAffectedSpecifier, isValidFeedPayload, loadRemoteFeed } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
import {
|
||||||
|
defaultChecksumsUrl,
|
||||||
|
parseAffectedSpecifier,
|
||||||
|
loadLocalFeed,
|
||||||
|
loadRemoteFeed,
|
||||||
|
} from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
||||||
|
|
||||||
const DEFAULT_FEED_URL =
|
const DEFAULT_FEED_URL =
|
||||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||||
const DEFAULT_SUITE_DIR = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
const DEFAULT_SUITE_DIR = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||||
const DEFAULT_LOCAL_FEED = path.join(DEFAULT_SUITE_DIR, "advisories", "feed.json");
|
const DEFAULT_LOCAL_FEED = path.join(DEFAULT_SUITE_DIR, "advisories", "feed.json");
|
||||||
|
const DEFAULT_LOCAL_FEED_SIG = `${DEFAULT_LOCAL_FEED}.sig`;
|
||||||
|
const DEFAULT_LOCAL_FEED_CHECKSUMS = path.join(DEFAULT_SUITE_DIR, "advisories", "checksums.json");
|
||||||
|
const DEFAULT_LOCAL_FEED_CHECKSUMS_SIG = `${DEFAULT_LOCAL_FEED_CHECKSUMS}.sig`;
|
||||||
|
const DEFAULT_FEED_PUBLIC_KEY = path.join(DEFAULT_SUITE_DIR, "advisories", "feed-signing-public.pem");
|
||||||
const EXIT_CONFIRM_REQUIRED = 42;
|
const EXIT_CONFIRM_REQUIRED = 42;
|
||||||
|
|
||||||
function printUsage() {
|
function printUsage() {
|
||||||
@@ -87,12 +96,10 @@ function affectedSpecifierMatches(specifier, skillName, version) {
|
|||||||
return versionMatches(version, parsed.versionSpec);
|
return versionMatches(version, parsed.versionSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
function affectedSpecifierMatchesNameOnly(specifier, skillName) {
|
function affectedSpecifierMatchesWithoutVersion(specifier, skillName) {
|
||||||
const parsed = parseAffectedSpecifier(specifier);
|
const parsed = parseAffectedSpecifier(specifier);
|
||||||
if (!parsed) return false;
|
if (!parsed) return false;
|
||||||
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false;
|
return normalizeSkillName(parsed.name) === normalizeSkillName(skillName);
|
||||||
const vs = parsed.versionSpec.trim();
|
|
||||||
return !vs || vs === "*" || vs.toLowerCase() === "any";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function advisoryLooksHighRisk(advisory) {
|
function advisoryLooksHighRisk(advisory) {
|
||||||
@@ -108,17 +115,47 @@ function advisoryLooksHighRisk(advisory) {
|
|||||||
|
|
||||||
async function loadFeed() {
|
async function loadFeed() {
|
||||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||||
|
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||||
|
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||||
|
const feedChecksumsSignatureUrl = process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||||
const localFeedPath = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED;
|
const localFeedPath = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED;
|
||||||
|
const localFeedSigPath = process.env.CLAWSEC_LOCAL_FEED_SIG || DEFAULT_LOCAL_FEED_SIG;
|
||||||
|
const localFeedChecksumsPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || DEFAULT_LOCAL_FEED_CHECKSUMS;
|
||||||
|
const localFeedChecksumsSigPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || DEFAULT_LOCAL_FEED_CHECKSUMS_SIG;
|
||||||
|
const feedPublicKeyPath = process.env.CLAWSEC_FEED_PUBLIC_KEY || DEFAULT_FEED_PUBLIC_KEY;
|
||||||
|
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||||
|
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||||
|
|
||||||
const remoteFeed = await loadRemoteFeed(feedUrl);
|
if (allowUnsigned) {
|
||||||
|
process.stderr.write(
|
||||||
|
"WARNING: CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. This temporary migration compatibility bypass should be removed once signed feed artifacts are available.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyPem = allowUnsigned ? "" : await fs.readFile(feedPublicKeyPath, "utf8");
|
||||||
|
|
||||||
|
const remoteFeed = await loadRemoteFeed(feedUrl, {
|
||||||
|
signatureUrl: feedSignatureUrl,
|
||||||
|
checksumsUrl: feedChecksumsUrl,
|
||||||
|
checksumsSignatureUrl: feedChecksumsSignatureUrl,
|
||||||
|
publicKeyPem,
|
||||||
|
checksumsPublicKeyPem: publicKeyPem,
|
||||||
|
allowUnsigned,
|
||||||
|
verifyChecksumManifest,
|
||||||
|
});
|
||||||
if (remoteFeed) return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
if (remoteFeed) return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
||||||
|
|
||||||
const raw = await fs.readFile(localFeedPath, "utf8");
|
const localFeed = await loadLocalFeed(localFeedPath, {
|
||||||
const payload = JSON.parse(raw);
|
signaturePath: localFeedSigPath,
|
||||||
if (!isValidFeedPayload(payload)) {
|
checksumsPath: localFeedChecksumsPath,
|
||||||
throw new Error(`Invalid fallback advisory feed format: ${localFeedPath}`);
|
checksumsSignaturePath: localFeedChecksumsSigPath,
|
||||||
}
|
publicKeyPem,
|
||||||
return { feed: payload, source: `local:${localFeedPath}` };
|
checksumsPublicKeyPem: publicKeyPem,
|
||||||
|
allowUnsigned,
|
||||||
|
verifyChecksumManifest,
|
||||||
|
checksumPublicKeyEntry: path.basename(feedPublicKeyPath),
|
||||||
|
});
|
||||||
|
return { feed: localFeed, source: `local:${localFeedPath}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
function findMatches(feed, skillName, version) {
|
function findMatches(feed, skillName, version) {
|
||||||
@@ -133,7 +170,7 @@ function findMatches(feed, skillName, version) {
|
|||||||
affected.filter((specifier) =>
|
affected.filter((specifier) =>
|
||||||
version
|
version
|
||||||
? affectedSpecifierMatches(specifier, skillName, version)
|
? affectedSpecifierMatches(specifier, skillName, version)
|
||||||
: affectedSpecifierMatchesNameOnly(specifier, skillName),
|
: affectedSpecifierMatchesWithoutVersion(specifier, skillName),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -186,6 +223,12 @@ async function main() {
|
|||||||
|
|
||||||
process.stdout.write(`Advisory source: ${source}\n`);
|
process.stdout.write(`Advisory source: ${source}\n`);
|
||||||
|
|
||||||
|
if (!args.version) {
|
||||||
|
process.stdout.write(
|
||||||
|
"No --version provided. Conservatively matching any advisory for the requested skill name.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (matches.length > 0) {
|
if (matches.length > 0) {
|
||||||
printMatches(matches, args.skill, args.version);
|
printMatches(matches, args.skill, args.version);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
process.stderr.write(
|
||||||
|
[
|
||||||
|
"Usage:",
|
||||||
|
" node scripts/sign_detached_ed25519.mjs --key <private-key.pem> --in <file> --out <file.sig>",
|
||||||
|
"",
|
||||||
|
"Signs <file> with Ed25519 private key and writes base64 detached signature to <file.sig>.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const parsed = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (token === "--key") {
|
||||||
|
parsed.keyPath = argv[++i];
|
||||||
|
} else if (token === "--in") {
|
||||||
|
parsed.inPath = argv[++i];
|
||||||
|
} else if (token === "--out") {
|
||||||
|
parsed.outPath = argv[++i];
|
||||||
|
} else if (token === "-h" || token === "--help") {
|
||||||
|
parsed.help = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown argument: ${token}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { keyPath, inPath, outPath, help } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (help) {
|
||||||
|
usage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keyPath || !inPath || !outPath) {
|
||||||
|
usage();
|
||||||
|
throw new Error("Missing required arguments: --key, --in, --out");
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKeyPem = await fs.readFile(keyPath, "utf8");
|
||||||
|
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||||
|
const data = await fs.readFile(inPath);
|
||||||
|
const signature = crypto.sign(null, data, privateKey);
|
||||||
|
const signatureBase64 = signature.toString("base64");
|
||||||
|
|
||||||
|
await fs.writeFile(outPath, `${signatureBase64}\n`, "utf8");
|
||||||
|
process.stdout.write(`Signed ${inPath} -> ${outPath}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
process.stderr.write(
|
||||||
|
[
|
||||||
|
"Usage:",
|
||||||
|
" node scripts/verify_detached_ed25519.mjs --key <public-key.pem> --in <file> --sig <file.sig>",
|
||||||
|
"",
|
||||||
|
"Verifies Ed25519 detached signature against <file>.",
|
||||||
|
"Exits 0 on success, 1 on verification failure.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const parsed = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (token === "--key") {
|
||||||
|
parsed.keyPath = argv[++i];
|
||||||
|
} else if (token === "--in") {
|
||||||
|
parsed.inPath = argv[++i];
|
||||||
|
} else if (token === "--sig") {
|
||||||
|
parsed.sigPath = argv[++i];
|
||||||
|
} else if (token === "-h" || token === "--help") {
|
||||||
|
parsed.help = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown argument: ${token}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { keyPath, inPath, sigPath, help } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (help) {
|
||||||
|
usage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keyPath || !inPath || !sigPath) {
|
||||||
|
usage();
|
||||||
|
throw new Error("Missing required arguments: --key, --in, --sig");
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyPem = await fs.readFile(keyPath, "utf8");
|
||||||
|
const publicKey = crypto.createPublicKey(publicKeyPem);
|
||||||
|
const data = await fs.readFile(inPath);
|
||||||
|
const signatureRaw = await fs.readFile(sigPath, "utf8");
|
||||||
|
const signature = Buffer.from(signatureRaw.trim(), "base64");
|
||||||
|
|
||||||
|
const valid = crypto.verify(null, data, publicKey, signature);
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
process.stdout.write(`Signature valid: ${inPath}\n`);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
process.stderr.write(`Signature INVALID: ${inPath}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "clawsec-suite",
|
"name": "clawsec-suite",
|
||||||
"version": "0.0.9",
|
"version": "0.0.10",
|
||||||
"description": "ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
"description": "ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
||||||
"author": "prompt-security",
|
"author": "prompt-security",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://clawsec.prompt.security/",
|
"homepage": "https://clawsec.prompt.security/",
|
||||||
@@ -19,7 +19,9 @@
|
|||||||
"agents",
|
"agents",
|
||||||
"ai",
|
"ai",
|
||||||
"suite",
|
"suite",
|
||||||
"openclaw"
|
"openclaw",
|
||||||
|
"signature",
|
||||||
|
"verification"
|
||||||
],
|
],
|
||||||
"sbom": {
|
"sbom": {
|
||||||
"files": [
|
"files": [
|
||||||
@@ -28,6 +30,11 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"description": "Suite skill documentation and installation guide"
|
"description": "Suite skill documentation and installation guide"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "CHANGELOG.md",
|
||||||
|
"required": true,
|
||||||
|
"description": "Version history and security improvements changelog"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "HEARTBEAT.md",
|
"path": "HEARTBEAT.md",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -38,6 +45,26 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"description": "Embedded advisory feed seed (merged from clawsec-feed)"
|
"description": "Embedded advisory feed seed (merged from clawsec-feed)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "advisories/feed.json.sig",
|
||||||
|
"required": true,
|
||||||
|
"description": "Detached Ed25519 signature for advisory feed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "advisories/checksums.json",
|
||||||
|
"required": true,
|
||||||
|
"description": "SHA-256 checksum manifest for advisory artifacts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "advisories/checksums.json.sig",
|
||||||
|
"required": true,
|
||||||
|
"description": "Detached Ed25519 signature for checksum manifest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "advisories/feed-signing-public.pem",
|
||||||
|
"required": true,
|
||||||
|
"description": "Pinned Ed25519 public key for feed signature verification"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "hooks/clawsec-advisory-guardian/HOOK.md",
|
"path": "hooks/clawsec-advisory-guardian/HOOK.md",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -46,7 +73,7 @@
|
|||||||
{
|
{
|
||||||
"path": "hooks/clawsec-advisory-guardian/handler.ts",
|
"path": "hooks/clawsec-advisory-guardian/handler.ts",
|
||||||
"required": true,
|
"required": true,
|
||||||
"description": "OpenClaw hook handler for approval-gated advisory actions"
|
"description": "OpenClaw hook handler for approval-gated advisory actions with signature verification"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "hooks/clawsec-advisory-guardian/lib/utils.mjs",
|
"path": "hooks/clawsec-advisory-guardian/lib/utils.mjs",
|
||||||
@@ -61,7 +88,22 @@
|
|||||||
{
|
{
|
||||||
"path": "hooks/clawsec-advisory-guardian/lib/feed.mjs",
|
"path": "hooks/clawsec-advisory-guardian/lib/feed.mjs",
|
||||||
"required": true,
|
"required": true,
|
||||||
"description": "Shared advisory feed loading and validation"
|
"description": "Advisory feed loading with Ed25519 signature and checksum manifest verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/clawsec-advisory-guardian/lib/types.ts",
|
||||||
|
"required": true,
|
||||||
|
"description": "TypeScript type definitions for hook and feed structures"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/clawsec-advisory-guardian/lib/state.ts",
|
||||||
|
"required": true,
|
||||||
|
"description": "Advisory state persistence and loading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "hooks/clawsec-advisory-guardian/lib/matching.ts",
|
||||||
|
"required": true,
|
||||||
|
"description": "Advisory-to-skill matching and alert message generation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/setup_advisory_hook.mjs",
|
"path": "scripts/setup_advisory_hook.mjs",
|
||||||
@@ -76,7 +118,22 @@
|
|||||||
{
|
{
|
||||||
"path": "scripts/guarded_skill_install.mjs",
|
"path": "scripts/guarded_skill_install.mjs",
|
||||||
"required": true,
|
"required": true,
|
||||||
"description": "Two-step confirmation installer that blocks risky skill installs until explicit second approval"
|
"description": "Two-step confirmation installer with signature verification that blocks risky skill installs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/sign_detached_ed25519.mjs",
|
||||||
|
"required": false,
|
||||||
|
"description": "Utility script for generating Ed25519 detached signatures"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/verify_detached_ed25519.mjs",
|
||||||
|
"required": false,
|
||||||
|
"description": "Utility script for verifying Ed25519 detached signatures"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/generate_checksums_json.mjs",
|
||||||
|
"required": false,
|
||||||
|
"description": "Utility script for generating SHA-256 checksum manifests"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -85,14 +142,20 @@
|
|||||||
"source_skill": "clawsec-feed",
|
"source_skill": "clawsec-feed",
|
||||||
"source_version": "0.0.4",
|
"source_version": "0.0.4",
|
||||||
"paths": [
|
"paths": [
|
||||||
"advisories/feed.json"
|
"advisories/feed.json",
|
||||||
|
"advisories/feed.json.sig",
|
||||||
|
"advisories/checksums.json",
|
||||||
|
"advisories/checksums.json.sig",
|
||||||
|
"advisories/feed-signing-public.pem"
|
||||||
],
|
],
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"advisory-feed monitoring",
|
"advisory-feed monitoring",
|
||||||
"new-advisory detection",
|
"new-advisory detection",
|
||||||
"affected-skill cross-reference",
|
"affected-skill cross-reference",
|
||||||
"approval-gated malicious-skill removal recommendations",
|
"approval-gated malicious-skill removal recommendations",
|
||||||
"double-confirmation gating for risky skill installs"
|
"double-confirmation gating for risky skill installs",
|
||||||
|
"Ed25519 signature verification",
|
||||||
|
"checksum manifest verification"
|
||||||
],
|
],
|
||||||
"standalone_available": true,
|
"standalone_available": true,
|
||||||
"deprecation_plan": "standalone skill may be retired after suite migration is verified"
|
"deprecation_plan": "standalone skill may be retired after suite migration is verified"
|
||||||
@@ -153,7 +216,8 @@
|
|||||||
"bins": [
|
"bins": [
|
||||||
"curl",
|
"curl",
|
||||||
"jq",
|
"jq",
|
||||||
"shasum"
|
"shasum",
|
||||||
|
"openssl"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"triggers": [
|
"triggers": [
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed verification tests for clawsec-suite.
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Signature verification success/failure/tampered cases
|
||||||
|
* - Checksum manifest verification success/failure/tampered cases
|
||||||
|
* - Fail-closed behavior when signatures are missing/invalid
|
||||||
|
* - Temporary compatibility flag behavior
|
||||||
|
*
|
||||||
|
* Run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
|
||||||
|
|
||||||
|
// Dynamic import to ensure we test the actual module
|
||||||
|
const { verifySignedPayload, loadLocalFeed, isValidFeedPayload } = await import(
|
||||||
|
`${LIB_PATH}/feed.mjs`
|
||||||
|
);
|
||||||
|
|
||||||
|
let tempDir;
|
||||||
|
let passCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
function pass(name) {
|
||||||
|
passCount++;
|
||||||
|
console.log(`✓ ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(name, error) {
|
||||||
|
failCount++;
|
||||||
|
console.error(`✗ ${name}`);
|
||||||
|
console.error(` ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateEd25519KeyPair() {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||||
|
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||||
|
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||||
|
return { publicKeyPem, privateKeyPem };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(data, privateKeyPem) {
|
||||||
|
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||||
|
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
|
||||||
|
return signature.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createValidFeed() {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
updated: "2026-02-08T12:00:00Z",
|
||||||
|
advisories: [
|
||||||
|
{
|
||||||
|
id: "TEST-001",
|
||||||
|
severity: "high",
|
||||||
|
affected: ["test-skill@1.0.0"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChecksumManifest(files) {
|
||||||
|
const checksums = {};
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
schema_version: "1.0",
|
||||||
|
algorithm: "sha256",
|
||||||
|
files: checksums,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupTestDir() {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestDir() {
|
||||||
|
if (tempDir) {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: verifySignedPayload - valid signature
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testVerifySignedPayload_ValidSignature() {
|
||||||
|
const testName = "verifySignedPayload: valid signature passes";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||||
|
const payload = "test payload content";
|
||||||
|
const signature = signPayload(payload, privateKeyPem);
|
||||||
|
|
||||||
|
const result = verifySignedPayload(payload, signature, publicKeyPem);
|
||||||
|
|
||||||
|
if (result === true) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected true, got false");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: verifySignedPayload - invalid signature
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testVerifySignedPayload_InvalidSignature() {
|
||||||
|
const testName = "verifySignedPayload: invalid signature fails";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||||
|
const payload = "test payload content";
|
||||||
|
const signature = signPayload(payload, privateKeyPem);
|
||||||
|
|
||||||
|
// Tamper with payload
|
||||||
|
const tamperedPayload = "TAMPERED payload content";
|
||||||
|
const result = verifySignedPayload(tamperedPayload, signature, publicKeyPem);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected false for tampered payload, got true");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: verifySignedPayload - wrong key
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testVerifySignedPayload_WrongKey() {
|
||||||
|
const testName = "verifySignedPayload: wrong key fails";
|
||||||
|
try {
|
||||||
|
const keyPair1 = generateEd25519KeyPair();
|
||||||
|
const keyPair2 = generateEd25519KeyPair();
|
||||||
|
const payload = "test payload content";
|
||||||
|
const signature = signPayload(payload, keyPair1.privateKeyPem);
|
||||||
|
|
||||||
|
// Verify with different public key
|
||||||
|
const result = verifySignedPayload(payload, signature, keyPair2.publicKeyPem);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected false for wrong key, got true");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: verifySignedPayload - malformed signature
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testVerifySignedPayload_MalformedSignature() {
|
||||||
|
const testName = "verifySignedPayload: malformed signature fails";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem } = generateEd25519KeyPair();
|
||||||
|
const payload = "test payload content";
|
||||||
|
|
||||||
|
const result = verifySignedPayload(payload, "not-valid-base64!!!", publicKeyPem);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected false for malformed signature, got true");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: verifySignedPayload - empty signature
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testVerifySignedPayload_EmptySignature() {
|
||||||
|
const testName = "verifySignedPayload: empty signature fails";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem } = generateEd25519KeyPair();
|
||||||
|
const payload = "test payload content";
|
||||||
|
|
||||||
|
const result = verifySignedPayload(payload, "", publicKeyPem);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected false for empty signature, got true");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: verifySignedPayload - JSON-wrapped signature format
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testVerifySignedPayload_JsonWrappedSignature() {
|
||||||
|
const testName = "verifySignedPayload: JSON-wrapped signature passes";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||||
|
const payload = "test payload content";
|
||||||
|
const signatureBase64 = signPayload(payload, privateKeyPem);
|
||||||
|
const jsonWrapped = JSON.stringify({ signature: signatureBase64 });
|
||||||
|
|
||||||
|
const result = verifySignedPayload(payload, jsonWrapped, publicKeyPem);
|
||||||
|
|
||||||
|
if (result === true) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected true for JSON-wrapped signature, got false");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: loadLocalFeed - valid signed feed
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testLoadLocalFeed_ValidSignedFeed() {
|
||||||
|
const testName = "loadLocalFeed: valid signed feed loads successfully";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||||
|
const feedContent = createValidFeed();
|
||||||
|
const feedSignature = signPayload(feedContent, privateKeyPem);
|
||||||
|
|
||||||
|
// Create checksum manifest
|
||||||
|
const checksumManifest = createChecksumManifest({
|
||||||
|
"feed.json": feedContent,
|
||||||
|
"feed.json.sig": feedSignature + "\n",
|
||||||
|
"feed-signing-public.pem": publicKeyPem,
|
||||||
|
});
|
||||||
|
const checksumSignature = signPayload(checksumManifest, privateKeyPem);
|
||||||
|
|
||||||
|
// Write files
|
||||||
|
const feedPath = path.join(tempDir, "feed.json");
|
||||||
|
const sigPath = path.join(tempDir, "feed.json.sig");
|
||||||
|
const checksumPath = path.join(tempDir, "checksums.json");
|
||||||
|
const checksumSigPath = path.join(tempDir, "checksums.json.sig");
|
||||||
|
const keyPath = path.join(tempDir, "feed-signing-public.pem");
|
||||||
|
|
||||||
|
await fs.writeFile(feedPath, feedContent);
|
||||||
|
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||||
|
await fs.writeFile(checksumPath, checksumManifest);
|
||||||
|
await fs.writeFile(checksumSigPath, checksumSignature + "\n");
|
||||||
|
await fs.writeFile(keyPath, publicKeyPem);
|
||||||
|
|
||||||
|
const feed = await loadLocalFeed(feedPath, {
|
||||||
|
signaturePath: sigPath,
|
||||||
|
checksumsPath: checksumPath,
|
||||||
|
checksumsSignaturePath: checksumSigPath,
|
||||||
|
publicKeyPem,
|
||||||
|
verifyChecksumManifest: true,
|
||||||
|
checksumPublicKeyEntry: "feed-signing-public.pem",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (feed && feed.version === "1.0.0" && feed.advisories.length === 1) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Feed did not load with expected content");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: loadLocalFeed - tampered feed fails (fail-closed)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testLoadLocalFeed_TamperedFeedFails() {
|
||||||
|
const testName = "loadLocalFeed: tampered feed fails (fail-closed)";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||||
|
const feedContent = createValidFeed();
|
||||||
|
const feedSignature = signPayload(feedContent, privateKeyPem);
|
||||||
|
|
||||||
|
// Tamper with feed after signing
|
||||||
|
const tamperedFeed = feedContent.replace("TEST-001", "TAMPERED-001");
|
||||||
|
|
||||||
|
const feedPath = path.join(tempDir, "tampered-feed.json");
|
||||||
|
const sigPath = path.join(tempDir, "tampered-feed.json.sig");
|
||||||
|
|
||||||
|
await fs.writeFile(feedPath, tamperedFeed);
|
||||||
|
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||||
|
|
||||||
|
let didFail = false;
|
||||||
|
try {
|
||||||
|
await loadLocalFeed(feedPath, {
|
||||||
|
signaturePath: sigPath,
|
||||||
|
publicKeyPem,
|
||||||
|
verifyChecksumManifest: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
didFail = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didFail) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected failure for tampered feed, but it loaded");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: loadLocalFeed - missing signature fails (fail-closed)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testLoadLocalFeed_MissingSignatureFails() {
|
||||||
|
const testName = "loadLocalFeed: missing signature fails (fail-closed)";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem } = generateEd25519KeyPair();
|
||||||
|
const feedContent = createValidFeed();
|
||||||
|
|
||||||
|
const feedPath = path.join(tempDir, "nosig-feed.json");
|
||||||
|
const sigPath = path.join(tempDir, "nosig-feed.json.sig");
|
||||||
|
|
||||||
|
await fs.writeFile(feedPath, feedContent);
|
||||||
|
// Don't write signature file
|
||||||
|
|
||||||
|
let didFail = false;
|
||||||
|
try {
|
||||||
|
await loadLocalFeed(feedPath, {
|
||||||
|
signaturePath: sigPath,
|
||||||
|
publicKeyPem,
|
||||||
|
verifyChecksumManifest: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
didFail = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didFail) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected failure for missing signature, but it loaded");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: loadLocalFeed - allowUnsigned bypasses verification
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testLoadLocalFeed_AllowUnsignedBypasses() {
|
||||||
|
const testName = "loadLocalFeed: allowUnsigned=true bypasses verification";
|
||||||
|
try {
|
||||||
|
const feedContent = createValidFeed();
|
||||||
|
|
||||||
|
const feedPath = path.join(tempDir, "unsigned-feed.json");
|
||||||
|
await fs.writeFile(feedPath, feedContent);
|
||||||
|
|
||||||
|
const feed = await loadLocalFeed(feedPath, {
|
||||||
|
allowUnsigned: true,
|
||||||
|
verifyChecksumManifest: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (feed && feed.version === "1.0.0") {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Feed did not load with allowUnsigned=true");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: loadLocalFeed - checksum mismatch fails
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testLoadLocalFeed_ChecksumMismatchFails() {
|
||||||
|
const testName = "loadLocalFeed: checksum mismatch fails";
|
||||||
|
try {
|
||||||
|
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||||
|
const feedContent = createValidFeed();
|
||||||
|
const feedSignature = signPayload(feedContent, privateKeyPem);
|
||||||
|
|
||||||
|
// Create checksum manifest with WRONG hash
|
||||||
|
const badChecksumManifest = JSON.stringify(
|
||||||
|
{
|
||||||
|
schema_version: "1.0",
|
||||||
|
algorithm: "sha256",
|
||||||
|
files: {
|
||||||
|
"feed.json": "0".repeat(64), // Wrong hash
|
||||||
|
"feed.json.sig":
|
||||||
|
crypto.createHash("sha256").update(feedSignature + "\n").digest("hex"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const checksumSignature = signPayload(badChecksumManifest, privateKeyPem);
|
||||||
|
|
||||||
|
const feedPath = path.join(tempDir, "badcs-feed.json");
|
||||||
|
const sigPath = path.join(tempDir, "badcs-feed.json.sig");
|
||||||
|
const checksumPath = path.join(tempDir, "badcs-checksums.json");
|
||||||
|
const checksumSigPath = path.join(tempDir, "badcs-checksums.json.sig");
|
||||||
|
|
||||||
|
await fs.writeFile(feedPath, feedContent);
|
||||||
|
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||||
|
await fs.writeFile(checksumPath, badChecksumManifest);
|
||||||
|
await fs.writeFile(checksumSigPath, checksumSignature + "\n");
|
||||||
|
|
||||||
|
let didFail = false;
|
||||||
|
try {
|
||||||
|
await loadLocalFeed(feedPath, {
|
||||||
|
signaturePath: sigPath,
|
||||||
|
checksumsPath: checksumPath,
|
||||||
|
checksumsSignaturePath: checksumSigPath,
|
||||||
|
publicKeyPem,
|
||||||
|
verifyChecksumManifest: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
didFail = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didFail) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected failure for checksum mismatch, but it loaded");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: isValidFeedPayload - valid feed
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testIsValidFeedPayload_Valid() {
|
||||||
|
const testName = "isValidFeedPayload: valid feed passes";
|
||||||
|
try {
|
||||||
|
const feed = {
|
||||||
|
version: "1.0.0",
|
||||||
|
advisories: [
|
||||||
|
{
|
||||||
|
id: "TEST-001",
|
||||||
|
severity: "high",
|
||||||
|
affected: ["test-skill@1.0.0"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isValidFeedPayload(feed)) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected valid feed to pass validation");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: isValidFeedPayload - missing version fails
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testIsValidFeedPayload_MissingVersion() {
|
||||||
|
const testName = "isValidFeedPayload: missing version fails";
|
||||||
|
try {
|
||||||
|
const feed = {
|
||||||
|
advisories: [
|
||||||
|
{
|
||||||
|
id: "TEST-001",
|
||||||
|
severity: "high",
|
||||||
|
affected: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isValidFeedPayload(feed)) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected feed without version to fail validation");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: isValidFeedPayload - advisory missing id fails
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testIsValidFeedPayload_AdvisoryMissingId() {
|
||||||
|
const testName = "isValidFeedPayload: advisory missing id fails";
|
||||||
|
try {
|
||||||
|
const feed = {
|
||||||
|
version: "1.0.0",
|
||||||
|
advisories: [
|
||||||
|
{
|
||||||
|
severity: "high",
|
||||||
|
affected: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isValidFeedPayload(feed)) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, "Expected advisory without id to fail validation");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Main test runner
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function runTests() {
|
||||||
|
console.log("=== ClawSec Feed Verification Tests ===\n");
|
||||||
|
|
||||||
|
await setupTestDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Signature verification tests
|
||||||
|
await testVerifySignedPayload_ValidSignature();
|
||||||
|
await testVerifySignedPayload_InvalidSignature();
|
||||||
|
await testVerifySignedPayload_WrongKey();
|
||||||
|
await testVerifySignedPayload_MalformedSignature();
|
||||||
|
await testVerifySignedPayload_EmptySignature();
|
||||||
|
await testVerifySignedPayload_JsonWrappedSignature();
|
||||||
|
|
||||||
|
// Local feed loading tests
|
||||||
|
await testLoadLocalFeed_ValidSignedFeed();
|
||||||
|
await testLoadLocalFeed_TamperedFeedFails();
|
||||||
|
await testLoadLocalFeed_MissingSignatureFails();
|
||||||
|
await testLoadLocalFeed_AllowUnsignedBypasses();
|
||||||
|
await testLoadLocalFeed_ChecksumMismatchFails();
|
||||||
|
|
||||||
|
// Feed payload validation tests
|
||||||
|
await testIsValidFeedPayload_Valid();
|
||||||
|
await testIsValidFeedPayload_MissingVersion();
|
||||||
|
await testIsValidFeedPayload_AdvisoryMissingId();
|
||||||
|
} finally {
|
||||||
|
await cleanupTestDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||||
|
|
||||||
|
if (failCount > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests().catch((error) => {
|
||||||
|
console.error("Test runner failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarded skill install tests for clawsec-suite.
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Conservative matching when version is omitted
|
||||||
|
* - Precise version matching when version is provided
|
||||||
|
* - Exit code 42 for advisory match requiring confirmation
|
||||||
|
* - High-risk advisory detection
|
||||||
|
*
|
||||||
|
* Run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "guarded_skill_install.mjs");
|
||||||
|
|
||||||
|
let tempDir;
|
||||||
|
let passCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
function pass(name) {
|
||||||
|
passCount++;
|
||||||
|
console.log(`✓ ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(name, error) {
|
||||||
|
failCount++;
|
||||||
|
console.error(`✗ ${name}`);
|
||||||
|
console.error(` ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateEd25519KeyPair() {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||||
|
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||||
|
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||||
|
return { publicKeyPem, privateKeyPem };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(data, privateKeyPem) {
|
||||||
|
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||||
|
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
|
||||||
|
return signature.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFeed(advisories) {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
updated: "2026-02-08T12:00:00Z",
|
||||||
|
advisories,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChecksumManifest(files) {
|
||||||
|
const checksums = {};
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
schema_version: "1.0",
|
||||||
|
algorithm: "sha256",
|
||||||
|
files: checksums,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupTestDir() {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-install-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestDir() {
|
||||||
|
if (tempDir) {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupSignedFeed(advisories, keyPair) {
|
||||||
|
const feedContent = createFeed(advisories);
|
||||||
|
const feedSignature = signPayload(feedContent, keyPair.privateKeyPem);
|
||||||
|
|
||||||
|
const checksumManifest = createChecksumManifest({
|
||||||
|
"feed.json": feedContent,
|
||||||
|
"feed.json.sig": feedSignature + "\n",
|
||||||
|
"feed-signing-public.pem": keyPair.publicKeyPem,
|
||||||
|
});
|
||||||
|
const checksumSignature = signPayload(checksumManifest, keyPair.privateKeyPem);
|
||||||
|
|
||||||
|
const advisoriesDir = path.join(tempDir, "advisories");
|
||||||
|
await fs.mkdir(advisoriesDir, { recursive: true });
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(advisoriesDir, "feed.json"), feedContent);
|
||||||
|
await fs.writeFile(path.join(advisoriesDir, "feed.json.sig"), feedSignature + "\n");
|
||||||
|
await fs.writeFile(path.join(advisoriesDir, "checksums.json"), checksumManifest);
|
||||||
|
await fs.writeFile(path.join(advisoriesDir, "checksums.json.sig"), checksumSignature + "\n");
|
||||||
|
await fs.writeFile(path.join(advisoriesDir, "feed-signing-public.pem"), keyPair.publicKeyPem);
|
||||||
|
|
||||||
|
return advisoriesDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGuardedInstall(args, env) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = spawn("node", [SCRIPT_PATH, ...args], {
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
proc.stdout.on("data", (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
resolve({ code, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: Conservative matching when version is omitted
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testConservativeMatchingWithoutVersion() {
|
||||||
|
const testName = "guarded_install: conservative matching without version triggers advisory";
|
||||||
|
try {
|
||||||
|
const keyPair = generateEd25519KeyPair();
|
||||||
|
const advisoriesDir = await setupSignedFeed(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "TEST-001",
|
||||||
|
severity: "high",
|
||||||
|
affected: ["test-skill@1.0.0", "test-skill@1.0.1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
keyPair,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
||||||
|
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
||||||
|
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
||||||
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
||||||
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
||||||
|
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
||||||
|
CLAWSEC_FEED_URL: "file:///nonexistent", // Force local fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.code === 42 && result.stdout.includes("Conservative")) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, `Expected exit 42 with conservative message, got ${result.code}: ${result.stdout}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: Precise version matching
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testPreciseVersionMatching() {
|
||||||
|
const testName = "guarded_install: precise version matching only matches exact version";
|
||||||
|
try {
|
||||||
|
const keyPair = generateEd25519KeyPair();
|
||||||
|
const advisoriesDir = await setupSignedFeed(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "TEST-001",
|
||||||
|
severity: "high",
|
||||||
|
affected: ["test-skill@1.0.0"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
keyPair,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Version 2.0.0 should NOT match advisory for 1.0.0
|
||||||
|
const result = await runGuardedInstall(
|
||||||
|
["--skill", "test-skill", "--version", "2.0.0", "--dry-run"],
|
||||||
|
{
|
||||||
|
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
||||||
|
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
||||||
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
||||||
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
||||||
|
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
||||||
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.code === 0 && !result.stdout.includes("Advisory matches")) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, `Expected exit 0 without match, got ${result.code}: ${result.stdout}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: Version match triggers confirmation requirement
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testVersionMatchTriggersConfirmation() {
|
||||||
|
const testName = "guarded_install: matching version triggers exit 42";
|
||||||
|
try {
|
||||||
|
const keyPair = generateEd25519KeyPair();
|
||||||
|
const advisoriesDir = await setupSignedFeed(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "TEST-001",
|
||||||
|
severity: "high",
|
||||||
|
affected: ["test-skill@1.0.0"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
keyPair,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runGuardedInstall(
|
||||||
|
["--skill", "test-skill", "--version", "1.0.0", "--dry-run"],
|
||||||
|
{
|
||||||
|
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
||||||
|
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
||||||
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
||||||
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
||||||
|
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
||||||
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.code === 42 && result.stdout.includes("Advisory matches")) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, `Expected exit 42 with advisory match, got ${result.code}: ${result.stdout}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: --confirm-advisory allows proceeding
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testConfirmAdvisoryAllowsProceeding() {
|
||||||
|
const testName = "guarded_install: --confirm-advisory with --dry-run proceeds";
|
||||||
|
try {
|
||||||
|
const keyPair = generateEd25519KeyPair();
|
||||||
|
const advisoriesDir = await setupSignedFeed(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "TEST-001",
|
||||||
|
severity: "high",
|
||||||
|
affected: ["test-skill@1.0.0"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
keyPair,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runGuardedInstall(
|
||||||
|
["--skill", "test-skill", "--version", "1.0.0", "--confirm-advisory", "--dry-run"],
|
||||||
|
{
|
||||||
|
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
||||||
|
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
||||||
|
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
||||||
|
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
||||||
|
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
||||||
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.code === 0 && result.stdout.includes("Dry run")) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, `Expected exit 0 with dry run message, got ${result.code}: ${result.stdout}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: allowUnsigned bypass warning
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testAllowUnsignedWarning() {
|
||||||
|
const testName = "guarded_install: CLAWSEC_ALLOW_UNSIGNED_FEED shows warning";
|
||||||
|
try {
|
||||||
|
// Create unsigned feed (no signatures)
|
||||||
|
const feedContent = createFeed([]);
|
||||||
|
const feedPath = path.join(tempDir, "unsigned-feed.json");
|
||||||
|
await fs.writeFile(feedPath, feedContent);
|
||||||
|
|
||||||
|
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
||||||
|
CLAWSEC_LOCAL_FEED: feedPath,
|
||||||
|
CLAWSEC_ALLOW_UNSIGNED_FEED: "1",
|
||||||
|
CLAWSEC_VERIFY_CHECKSUM_MANIFEST: "0",
|
||||||
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.stderr.includes("CLAWSEC_ALLOW_UNSIGNED_FEED")) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, `Expected unsigned mode warning, got: ${result.stderr}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Test: Missing signature fails without allowUnsigned
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function testMissingSignatureFails() {
|
||||||
|
const testName = "guarded_install: missing signature fails without allowUnsigned";
|
||||||
|
try {
|
||||||
|
const feedContent = createFeed([]);
|
||||||
|
const feedPath = path.join(tempDir, "nosig-feed.json");
|
||||||
|
await fs.writeFile(feedPath, feedContent);
|
||||||
|
|
||||||
|
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
||||||
|
CLAWSEC_LOCAL_FEED: feedPath,
|
||||||
|
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.code === 1) {
|
||||||
|
pass(testName);
|
||||||
|
} else {
|
||||||
|
fail(testName, `Expected exit 1 for missing signature, got ${result.code}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(testName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Main test runner
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
async function runTests() {
|
||||||
|
console.log("=== ClawSec Guarded Install Tests ===\n");
|
||||||
|
|
||||||
|
await setupTestDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testConservativeMatchingWithoutVersion();
|
||||||
|
await testPreciseVersionMatching();
|
||||||
|
await testVersionMatchTriggersConfirmation();
|
||||||
|
await testConfirmAdvisoryAllowsProceeding();
|
||||||
|
await testAllowUnsignedWarning();
|
||||||
|
await testMissingSignatureFails();
|
||||||
|
} finally {
|
||||||
|
await cleanupTestDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||||
|
|
||||||
|
if (failCount > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests().catch((error) => {
|
||||||
|
console.error("Test runner failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user