ClawSec init

This commit is contained in:
David Abutbul
2026-02-05 21:58:23 +02:00
commit d3c703aea6
107 changed files with 19160 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lint-typescript:
name: Lint TypeScript/React
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: ESLint
run: npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0
- name: TypeScript Check
run: npx tsc --noEmit
- name: Build Check
run: npm run build
lint-python:
name: Lint Python
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install linters
run: pip install ruff bandit
- name: Ruff (lint + format check)
run: ruff check utils/ --output-format=github
- name: Bandit (security)
run: bandit -r utils/ -ll
lint-shell:
name: Lint Shell Scripts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ShellCheck
uses: ludeeus/action-shellcheck@master
with:
scandir: './scripts'
severity: warning
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Trivy FS Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
exit-code: '1'
ignore-unfixed: true
- name: Trivy Config Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'config'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
exit-code: '1'
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: npm audit
run: npm audit --audit-level=high --registry=https://registry.npmjs.org
- name: Check for outdated deps
run: npm outdated || true
+251
View File
@@ -0,0 +1,251 @@
name: Process Community Advisory
on:
issues:
types: [labeled]
permissions:
contents: write
issues: write
concurrency:
group: community-advisory
cancel-in-progress: false
env:
FEED_PATH: advisories/feed.json
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
jobs:
process-advisory:
if: github.event.label.name == 'advisory-approved'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Parse issue and create advisory
id: parse
env:
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_URL: ${{ github.event.issue.html_url }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_CREATED_AT: ${{ github.event.issue.created_at }}
run: |
# Generate advisory ID: CLAW-YYYY-{issue_number padded to 4 digits}
# Use issue creation year, not current year, to ensure consistency
YEAR=$(echo "$ISSUE_CREATED_AT" | cut -c1-4)
PADDED_NUM=$(printf "%04d" "$ISSUE_NUMBER")
ADVISORY_ID="CLAW-${YEAR}-${PADDED_NUM}"
echo "advisory_id=$ADVISORY_ID" >> $GITHUB_OUTPUT
echo "Generated advisory ID: $ADVISORY_ID"
# Check if advisory already exists by issue URL (dedupe by issue, not by ID)
# This prevents duplicates when the same issue is labeled in different years
if jq -e --arg url "$ISSUE_URL" '.advisories[] | select(.github_issue_url == $url)' "$FEED_PATH" > /dev/null 2>&1; then
echo "Advisory for issue $ISSUE_URL already exists in feed"
echo "already_exists=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "already_exists=false" >> $GITHUB_OUTPUT
# Parse opener type (human vs agent)
if echo "$ISSUE_BODY" | grep -q '\[x\] Agent'; then
OPENER_TYPE="agent"
else
OPENER_TYPE="human"
fi
echo "Opener type: $OPENER_TYPE"
# Parse report type
if echo "$ISSUE_BODY" | grep -q '\[x\] Malicious Prompt'; then
REPORT_TYPE="prompt_injection"
elif echo "$ISSUE_BODY" | grep -q '\[x\] Vulnerable Skill'; then
REPORT_TYPE="vulnerable_skill"
elif echo "$ISSUE_BODY" | grep -q '\[x\] Tampering Attempt'; then
REPORT_TYPE="tampering_attempt"
else
REPORT_TYPE="unknown"
fi
echo "Report type: $REPORT_TYPE"
# Parse severity
if echo "$ISSUE_BODY" | grep -q '\[x\] Critical'; then
SEVERITY="critical"
elif echo "$ISSUE_BODY" | grep -q '\[x\] High'; then
SEVERITY="high"
elif echo "$ISSUE_BODY" | grep -q '\[x\] Medium'; then
SEVERITY="medium"
elif echo "$ISSUE_BODY" | grep -q '\[x\] Low'; then
SEVERITY="low"
else
SEVERITY="medium"
fi
echo "Severity: $SEVERITY"
# Parse title (between ## Title and ## Description)
TITLE=$(echo "$ISSUE_BODY" | sed -n '/^## Title/,/^## Description/p' | grep -v '^## ' | grep -v '^<!--' | grep -v '^\s*$' | head -1 | xargs)
if [ -z "$TITLE" ]; then
TITLE="$ISSUE_TITLE"
fi
echo "Title: $TITLE"
# Parse description (between ## Description and ---)
DESCRIPTION=$(echo "$ISSUE_BODY" | sed -n '/^## Description/,/^---/p' | grep -v '^## Description' | grep -v '^---' | grep -v '^<!--' | sed '/^\s*$/d' | tr '\n' ' ' | xargs)
if [ -z "$DESCRIPTION" ]; then
DESCRIPTION="See issue for details."
fi
echo "Description: ${DESCRIPTION:0:100}..."
# Parse skill name and version
SKILL_NAME=$(echo "$ISSUE_BODY" | sed -n '/^### Skill Name/,/^### /p' | grep -v '^### ' | grep -v '^<!--' | grep -v '^\s*$' | head -1 | xargs)
SKILL_VERSION=$(echo "$ISSUE_BODY" | sed -n '/^### Skill Version/,/^### /p' | grep -v '^### ' | grep -v '^<!--' | grep -v '^\s*$' | head -1 | xargs)
# Build affected array
AFFECTED="[]"
if [ -n "$SKILL_NAME" ] && [ -n "$SKILL_VERSION" ]; then
AFFECTED=$(jq -n --arg name "$SKILL_NAME" --arg ver "$SKILL_VERSION" '[$name + "@" + $ver]')
elif [ -n "$SKILL_NAME" ]; then
AFFECTED=$(jq -n --arg name "$SKILL_NAME" '[$name]')
fi
echo "Affected: $AFFECTED"
# Parse recommended action
ACTION=$(echo "$ISSUE_BODY" | sed -n '/^## Recommended Action/,/^---/p' | grep -v '^## Recommended Action' | grep -v '^---' | grep -v '^<!--' | sed '/^\s*$/d' | tr '\n' ' ' | xargs)
if [ -z "$ACTION" ]; then
ACTION="Review the advisory details and take appropriate action."
fi
echo "Action: ${ACTION:0:100}..."
# Parse reporter name
REPORTER_NAME=$(echo "$ISSUE_BODY" | grep -A1 '\*\*Agent/User Name:\*\*' | tail -1 | sed 's/\*\*Contact:\*\*.*//' | xargs)
if [ -z "$REPORTER_NAME" ]; then
REPORTER_NAME=$(echo "$ISSUE_BODY" | sed -n 's/.*\*\*Agent\/User Name:\*\*\s*\(.*\)/\1/p' | xargs)
fi
echo "Reporter: $REPORTER_NAME"
# Get current timestamp
PUBLISHED=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Create advisory JSON
jq -n \
--arg id "$ADVISORY_ID" \
--arg severity "$SEVERITY" \
--arg type "$REPORT_TYPE" \
--arg title "$TITLE" \
--arg description "$DESCRIPTION" \
--argjson affected "$AFFECTED" \
--arg action "$ACTION" \
--arg published "$PUBLISHED" \
--arg source "Community Report" \
--arg issue_url "$ISSUE_URL" \
--arg reporter_name "$REPORTER_NAME" \
--arg opener_type "$OPENER_TYPE" \
'{
id: $id,
severity: $severity,
type: $type,
title: $title,
description: $description,
affected: $affected,
action: $action,
published: $published,
references: [],
source: $source,
github_issue_url: $issue_url,
reporter: {
agent_name: $reporter_name,
opener_type: $opener_type
}
}' > tmp_advisory.json
echo "Created advisory JSON:"
cat tmp_advisory.json
- name: Update feed
if: steps.parse.outputs.already_exists != 'true'
run: |
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Add new advisory to feed
jq --argjson new "$(cat tmp_advisory.json)" --arg now "$NOW" '
.updated = $now |
.advisories = ([$new] + .advisories)
' "$FEED_PATH" > tmp_feed.json
# Validate JSON
if jq empty tmp_feed.json 2>/dev/null; then
echo "Feed JSON is valid"
mv tmp_feed.json "$FEED_PATH"
# Sync to skill feed
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
cp "$FEED_PATH" "$SKILL_FEED_PATH"
echo "Updated feeds:"
echo " - $FEED_PATH"
echo " - $SKILL_FEED_PATH"
TOTAL=$(jq '.advisories | length' "$FEED_PATH")
echo "Total advisories: $TOTAL"
else
echo "Error: Generated invalid JSON"
exit 1
fi
- name: Commit changes
if: steps.parse.outputs.already_exists != 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$FEED_PATH" "$SKILL_FEED_PATH"
ADVISORY_ID="${{ steps.parse.outputs.advisory_id }}"
git commit -m "chore: add community advisory $ADVISORY_ID
Added from issue #${{ github.event.issue.number }}
Issue: ${{ github.event.issue.html_url }}"
git push
- name: Comment on issue
if: steps.parse.outputs.already_exists != 'true'
uses: actions/github-script@v7
with:
script: |
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## Advisory Published
This security report has been published to the ClawSec advisory feed.
**Advisory ID:** \`${advisoryId}\`
The advisory is now available in the feed and will be picked up by agents on their next feed check.
Thank you for your contribution to community security!`
});
- name: Comment if already exists
if: steps.parse.outputs.already_exists == 'true'
uses: actions/github-script@v7
with:
script: |
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## Advisory Already Exists
An advisory with ID \`${advisoryId}\` already exists in the feed. No changes were made.
If this is a different issue, please open a new issue.`
});
+283
View File
@@ -0,0 +1,283 @@
name: Deploy to GitHub Pages
on:
workflow_run:
workflows: ["CI", "Skill Release"]
branches: [main]
types: [completed]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
# Only run if workflow_dispatch OR the triggering workflow succeeded
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Auto-discover skills from releases
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
mkdir -p public/skills
mkdir -p public/releases/download
echo "Fetching releases from GitHub API..."
# Helper function to download release asset by ID (works for private repos)
download_asset() {
local asset_id="$1"
local output_file="$2"
curl -fsSL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/octet-stream" \
"https://api.github.com/repos/${REPO}/releases/assets/${asset_id}" \
-o "$output_file"
}
export -f download_asset # Export for use in subshells (while loop)
# Fetch all releases
RELEASES=$(curl -sSL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${REPO}/releases?per_page=100")
# Start building skills index
echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json
FIRST_SKILL=true
PROCESSED_SKILLS=""
# Process each release (using process substitution to avoid subshell)
while read -r release; do
TAG=$(echo "$release" | jq -r '.tag_name')
# Parse skill-name-v* pattern
if [[ "$TAG" =~ ^(.+)-v([0-9]+\.[0-9]+\.[0-9]+.*)$ ]]; then
SKILL_NAME="${BASH_REMATCH[1]}"
VERSION="${BASH_REMATCH[2]}"
# Skip if we already processed a newer version of this skill
if echo "$PROCESSED_SKILLS" | grep -q "^${SKILL_NAME}$"; then
echo "Skipping older version: $TAG (already have newer)"
continue
fi
echo "Processing: $SKILL_NAME v$VERSION"
# Get skill.json asset ID from release
SKILL_JSON_ID=$(echo "$release" | jq -r '.assets[] | select(.name=="skill.json") | .id')
if [ -n "$SKILL_JSON_ID" ] && [ "$SKILL_JSON_ID" != "null" ]; then
# Basic safety checks before using tag/asset names as paths
if [[ "$TAG" == *"/"* ]] || [[ "$TAG" == *".."* ]]; then
echo " Warning: Skipping suspicious tag name: $TAG"
continue
fi
# Download skill.json first to decide whether the skill is internal
SKILL_JSON_TMP=$(mktemp)
download_asset "$SKILL_JSON_ID" "$SKILL_JSON_TMP"
# Skip internal skills (not shown in public catalog or mirrored)
IS_INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_JSON_TMP")
if [ "$IS_INTERNAL" = "true" ]; then
echo " Skipping internal skill: $SKILL_NAME"
rm -f "$SKILL_JSON_TMP"
continue
fi
# Mirror all release assets under a GitHub-compatible path so users can
# swap the host (github.com → clawsec.prompt.security) if GitHub is blocked.
MIRROR_DIR="public/releases/download/${TAG}"
mkdir -p "$MIRROR_DIR"
mv "$SKILL_JSON_TMP" "$MIRROR_DIR/skill.json"
# Download all remaining assets for this release (retain asset names)
while read -r asset; do
ASSET_ID=$(echo "$asset" | jq -r '.id')
ASSET_NAME=$(echo "$asset" | jq -r '.name')
# Prevent path traversal / nested directories
if [[ "$ASSET_NAME" == *"/"* ]] || [[ "$ASSET_NAME" == *".."* ]]; then
echo " Warning: Skipping suspicious asset name: $ASSET_NAME"
continue
fi
# Already downloaded above
if [ "$ASSET_NAME" = "skill.json" ]; then
continue
fi
download_asset "$ASSET_ID" "$MIRROR_DIR/$ASSET_NAME"
echo " Mirrored: $ASSET_NAME"
done < <(echo "$release" | jq -c '.assets[]')
# Copy the subset needed for the site catalog (skill pages)
mkdir -p "public/skills/${SKILL_NAME}"
cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json"
echo " Added to catalog: skill.json"
for file in checksums.json README.md SKILL.md; do
if [ -f "$MIRROR_DIR/$file" ]; then
cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file"
echo " Added to catalog: $file"
fi
done
# Build skill entry for index
SKILL_DATA=$(jq -c --arg tag "$TAG" '{
id: .name,
name: .name,
version: .version,
description: .description,
emoji: .openclaw.emoji,
category: .openclaw.category,
trust: .trust.level,
tag: $tag
}' "$MIRROR_DIR/skill.json")
# Append to index (handle first entry without comma)
if [ -f "public/skills/.first_done" ]; then
echo "," >> public/skills/index.json
else
touch "public/skills/.first_done"
fi
echo "$SKILL_DATA" >> public/skills/index.json
# Mark this skill as processed (track newest only)
PROCESSED_SKILLS="${PROCESSED_SKILLS}${SKILL_NAME}\n"
else
echo " Warning: skill.json not found in release assets"
fi
fi
done < <(echo "$RELEASES" | jq -c '.[]')
# Close the JSON array
echo ']}' >> public/skills/index.json
# Clean up temp file
rm -f "public/skills/.first_done"
echo ""
echo "=== Skills Index ==="
cat public/skills/index.json | jq . || cat public/skills/index.json
echo ""
echo "=== Skills Directory ==="
ls -la public/skills/
- name: Create root checksums placeholder
run: |
# Create empty checksums.json placeholder for root level
echo '{"version":"1.0.0","files":{}}' > public/checksums.json
echo "Created checksums.json placeholder"
- name: Copy advisory feed to public
run: |
mkdir -p public/advisories
cp advisories/feed.json public/advisories/feed.json
echo "Copied advisory feed to public/advisories/"
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Get latest clawsec-suite release URL
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
LATEST_TAG=$(curl -sSL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${REPO}/releases?per_page=100" | \
jq -r '[.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty')
if [ -n "$LATEST_TAG" ]; then
echo "Found latest clawsec-suite tag: $LATEST_TAG"
echo "VITE_CLAWSEC_SUITE_URL=https://clawsec.prompt.security/releases/download/${LATEST_TAG}/SKILL.md" >> $GITHUB_ENV
# Create a local "latest" mirror path for clients that use GitHub-style URLs.
# This enables swapping the host:
# https://github.com/<repo>/releases/latest/download/<file>
# → https://clawsec.prompt.security/releases/latest/download/<file>
MIRROR_TAG_DIR="public/releases/download/${LATEST_TAG}"
MIRROR_LATEST_DIR="public/releases/latest/download"
rm -rf "$MIRROR_LATEST_DIR"
mkdir -p "$MIRROR_LATEST_DIR"
if [ -d "$MIRROR_TAG_DIR" ]; then
cp -f "$MIRROR_TAG_DIR"/* "$MIRROR_LATEST_DIR"/ 2>/dev/null || true
echo "Mirrored suite release assets to: $MIRROR_LATEST_DIR"
else
echo "Warning: Suite release assets not mirrored (missing: $MIRROR_TAG_DIR)"
fi
# Mirror advisories feed at the path referenced by suite docs/heartbeat
if [ -f "public/advisories/feed.json" ]; then
mkdir -p "$MIRROR_LATEST_DIR/advisories"
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/advisories/feed.json"
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json"
fi
else
echo "No clawsec-suite release found, using fallback"
fi
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NODE_ENV: production
VITE_CLAWSEC_SUITE_URL: ${{ env.VITE_CLAWSEC_SUITE_URL }}
- name: Copy skills data to dist
run: |
cp -r public/skills dist/skills 2>/dev/null || echo "No skills directory"
cp public/checksums.json dist/checksums.json 2>/dev/null || echo "No legacy checksums"
cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory"
echo "=== Dist contents ==="
ls -la dist/
ls -la dist/skills/ 2>/dev/null || echo "No skills in dist"
ls -la dist/advisories/ 2>/dev/null || echo "No advisories in dist"
- name: Add .nojekyll file
run: touch dist/.nojekyll
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: ./dist
deploy:
# Deploy after build succeeds (CI or Skill Release must pass first, or manual dispatch)
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+428
View File
@@ -0,0 +1,428 @@
name: Poll NVD CVEs
on:
schedule:
# Run daily at 06:00 UTC
- cron: '0 6 * * *'
workflow_dispatch:
inputs:
force_full_scan:
description: 'Ignore last poll date and scan all CVEs'
required: false
default: 'false'
type: boolean
permissions:
contents: write
pull-requests: write
concurrency:
group: poll-nvd-cves
cancel-in-progress: false
env:
FEED_PATH: advisories/feed.json
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
KEYWORDS: "OpenClaw clawdbot Moltbot"
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
jobs:
poll-and-update:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Get last poll date from feed
id: last_poll
run: |
if [ -f "$FEED_PATH" ]; then
LAST_UPDATED=$(jq -r '.updated // empty' "$FEED_PATH")
if [ -n "$LAST_UPDATED" ] && [ "${{ inputs.force_full_scan }}" != "true" ]; then
echo "last_date=$LAST_UPDATED" >> $GITHUB_OUTPUT
echo "Found last updated: $LAST_UPDATED"
else
# Default to 120 days ago if no date found or force scan
LAST_UPDATED=$(date -u -d '120 days ago' +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u -v-120d +%Y-%m-%dT%H:%M:%S.000Z)
echo "last_date=$LAST_UPDATED" >> $GITHUB_OUTPUT
echo "Using default date: $LAST_UPDATED"
fi
else
LAST_UPDATED=$(date -u -d '120 days ago' +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u -v-120d +%Y-%m-%dT%H:%M:%S.000Z)
echo "last_date=$LAST_UPDATED" >> $GITHUB_OUTPUT
echo "No feed found, using default: $LAST_UPDATED"
fi
- name: Set date window
id: dates
run: |
START_DATE="${{ steps.last_poll.outputs.last_date }}"
END_DATE=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
# Convert to epoch for comparison
START_EPOCH=$(date -d "$START_DATE" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${START_DATE%.*}" +%s)
END_EPOCH=$(date -d "$END_DATE" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${END_DATE%.*}" +%s)
# Ensure start date is before end date (NVD returns 404 if start > end)
if [ "$START_EPOCH" -ge "$END_EPOCH" ]; then
echo "Warning: Start date ($START_DATE) is not before end date ($END_DATE)"
echo "Adjusting start date to 24 hours before end date"
START_EPOCH=$((END_EPOCH - 86400))
START_DATE=$(date -u -d "@$START_EPOCH" +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u -r "$START_EPOCH" +%Y-%m-%dT%H:%M:%S.000Z)
fi
echo "start_date=$START_DATE" >> $GITHUB_OUTPUT
echo "end_date=$END_DATE" >> $GITHUB_OUTPUT
echo "Polling window: $START_DATE to $END_DATE"
- name: Fetch CVEs from NVD
id: fetch
run: |
mkdir -p tmp
START_DATE="${{ steps.dates.outputs.start_date }}"
END_DATE="${{ steps.dates.outputs.end_date }}"
# URL encode the dates
START_ENC=$(echo "$START_DATE" | sed 's/:/%3A/g')
END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
echo "=== Fetching CVEs from NVD ==="
# Fetch for each keyword
for KEYWORD in $KEYWORDS; do
echo "Fetching keyword: $KEYWORD"
URL="https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=${KEYWORD}&lastModStartDate=${START_ENC}&lastModEndDate=${END_ENC}"
echo "URL: $URL"
# Fetch with retry logic
for i in 1 2 3; do
HTTP_CODE=$(curl -s -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL")
if [ "$HTTP_CODE" = "200" ]; then
echo "Success for $KEYWORD"
break
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
echo "Rate limited, waiting 30s before retry $i..."
sleep 30
else
echo "HTTP $HTTP_CODE for $KEYWORD, retry $i..."
sleep 5
fi
done
# NVD recommends 6 second delay between requests
sleep 6
done
echo "=== Fetch complete ==="
ls -la tmp/
- name: Merge and filter CVEs
id: process
run: |
# Combine all fetched CVEs
echo '{"vulnerabilities":[]}' > tmp/combined.json
for KEYWORD in $KEYWORDS; do
FILE="tmp/nvd_${KEYWORD}.json"
if [ -f "$FILE" ] && [ -s "$FILE" ]; then
# Check if file has vulnerabilities array
if jq -e '.vulnerabilities' "$FILE" > /dev/null 2>&1; then
COUNT=$(jq '.vulnerabilities | length' "$FILE")
echo "Found $COUNT CVEs for keyword search"
# Merge into combined
jq -s '.[0].vulnerabilities += .[1].vulnerabilities | .[0]' \
tmp/combined.json "$FILE" > tmp/combined_new.json
mv tmp/combined_new.json tmp/combined.json
fi
fi
done
# Deduplicate by CVE ID
jq '.vulnerabilities | unique_by(.cve.id)' tmp/combined.json > tmp/unique_cves.json
TOTAL=$(jq 'length' tmp/unique_cves.json)
echo "Total unique CVEs from NVD: $TOTAL"
# Post-filter: keep only CVEs where description contains keywords OR references contain github pattern
KEYWORDS_PATTERN="OpenClaw|clawdbot|Moltbot|openclaw"
GITHUB_PATTERN="${GITHUB_REF_PATTERN}"
jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_PATTERN" '
[.[] | select(
# Check if any description contains keywords (case insensitive)
(.cve.descriptions[]? | select(.lang == "en") | .value | test($kw; "i"))
or
# Check if any reference URL contains the github pattern
(.cve.references[]? | .url | test($gh; "i"))
)]
' tmp/unique_cves.json > tmp/filtered_cves.json
FILTERED=$(jq 'length' tmp/filtered_cves.json)
echo "Filtered CVEs (matching criteria): $FILTERED"
echo "filtered_count=$FILTERED" >> $GITHUB_OUTPUT
- name: Get existing advisories
id: existing
run: |
if [ -f "$FEED_PATH" ]; then
jq -r '.advisories[]?.id // empty' "$FEED_PATH" | sort -u > tmp/existing_ids.txt
# Also extract full existing advisories for update comparison
jq '.advisories // []' "$FEED_PATH" > tmp/existing_advisories.json
else
touch tmp/existing_ids.txt
echo '[]' > tmp/existing_advisories.json
fi
EXISTING_COUNT=$(wc -l < tmp/existing_ids.txt | tr -d ' ')
echo "Existing advisories: $EXISTING_COUNT"
cat tmp/existing_ids.txt
- name: Check for updates to existing advisories
id: updates
run: |
# Compare existing CVE advisories against NVD data for changes
# Only check advisories that start with "CVE-" (NVD-sourced)
jq '
def map_severity:
if . == null then "medium"
elif . >= 9.0 then "critical"
elif . >= 7.0 then "high"
elif . >= 4.0 then "medium"
else "low"
end;
def get_cvss_score:
.cve.metrics.cvssMetricV31[0]?.cvssData.baseScore //
.cve.metrics.cvssMetricV30[0]?.cvssData.baseScore //
.cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
null;
[.[] | {
id: .cve.id,
severity: (get_cvss_score | map_severity),
cvss_score: get_cvss_score,
description: (.cve.descriptions[] | select(.lang == "en") | .value),
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
references: [.cve.references[]?.url // empty] | unique | .[0:3]
}]
' tmp/filtered_cves.json > tmp/nvd_current_state.json
# Find updates: existing CVE advisories where NVD data differs
jq -n --slurpfile existing tmp/existing_advisories.json --slurpfile nvd tmp/nvd_current_state.json '
# Get only CVE-prefixed existing advisories
($existing[0] | map(select(.id | startswith("CVE-")))) as $cve_advisories |
# For each NVD entry, check if it exists and has changes
[
$nvd[0][] |
. as $nvd_entry |
($cve_advisories | map(select(.id == $nvd_entry.id)) | first) as $existing_entry |
if $existing_entry then
# Compare key fields
if ($existing_entry.severity != $nvd_entry.severity) or
($existing_entry.cvss_score != $nvd_entry.cvss_score) or
($existing_entry.description != $nvd_entry.description) then
{
id: $nvd_entry.id,
changes: (
[]
+ (if $existing_entry.severity != $nvd_entry.severity then ["severity: \($existing_entry.severity) → \($nvd_entry.severity)"] else [] end)
+ (if $existing_entry.cvss_score != $nvd_entry.cvss_score then ["cvss_score: \($existing_entry.cvss_score // "null") → \($nvd_entry.cvss_score // "null")"] else [] end)
+ (if $existing_entry.description != $nvd_entry.description then ["description updated"] else [] end)
),
updated_fields: {
severity: $nvd_entry.severity,
cvss_score: $nvd_entry.cvss_score,
description: $nvd_entry.description,
title: $nvd_entry.title,
references: $nvd_entry.references
}
}
else
empty
end
else
empty
end
]
' > tmp/updated_advisories.json
UPDATE_COUNT=$(jq 'length' tmp/updated_advisories.json)
echo "Advisories to update: $UPDATE_COUNT"
echo "update_count=$UPDATE_COUNT" >> $GITHUB_OUTPUT
if [ "$UPDATE_COUNT" -gt 0 ]; then
echo "=== Updated advisories ==="
jq -r '.[] | "- \(.id): \(.changes | join(", "))"' tmp/updated_advisories.json
fi
- name: Transform CVEs to advisories
id: transform
run: |
# Read existing IDs into a jq-friendly format
EXISTING_IDS=$(cat tmp/existing_ids.txt | jq -R -s 'split("\n") | map(select(length > 0))')
# Transform NVD CVEs to our advisory format
jq --argjson existing "$EXISTING_IDS" '
def map_severity:
if . == null then "medium"
elif . >= 9.0 then "critical"
elif . >= 7.0 then "high"
elif . >= 4.0 then "medium"
else "low"
end;
def get_cvss_score:
.cve.metrics.cvssMetricV31[0]?.cvssData.baseScore //
.cve.metrics.cvssMetricV30[0]?.cvssData.baseScore //
.cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
null;
[.[] |
select(.cve.id as $id | $existing | index($id) | not) |
{
id: .cve.id,
severity: (get_cvss_score | map_severity),
type: "vulnerable_skill",
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
description: (.cve.descriptions[] | select(.lang == "en") | .value),
affected: [.cve.configurations[]?.nodes[]?.cpeMatch[]?.criteria // empty] | unique | .[0:5],
action: "Review and update affected components. See NVD for remediation details.",
published: .cve.published,
references: [.cve.references[]?.url // empty] | unique | .[0:3],
cvss_score: get_cvss_score,
nvd_url: ("https://nvd.nist.gov/vuln/detail/" + .cve.id)
}
]
' tmp/filtered_cves.json > tmp/new_advisories.json
NEW_COUNT=$(jq 'length' tmp/new_advisories.json)
echo "New advisories to add: $NEW_COUNT"
echo "new_count=$NEW_COUNT" >> $GITHUB_OUTPUT
if [ "$NEW_COUNT" -gt 0 ]; then
echo "=== New advisories ==="
jq '.[].id' tmp/new_advisories.json
fi
- name: Update feed.json
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
run: |
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
if [ -f "$FEED_PATH" ]; then
# Step 1: Apply updates to existing advisories
jq --slurpfile updates tmp/updated_advisories.json '
.advisories = [
.advisories[] |
. as $adv |
($updates[0] | map(select(.id == $adv.id)) | first) as $update |
if $update then
# Merge updated fields
$adv * $update.updated_fields
else
$adv
end
]
' "$FEED_PATH" > tmp/feed_with_updates.json
# Step 2: Add new advisories
jq --argjson new "$(cat tmp/new_advisories.json)" --arg now "$NOW" '
.updated = $now |
.advisories = (.advisories + $new | sort_by(.published) | reverse)
' tmp/feed_with_updates.json > tmp/updated_feed.json
else
jq -n --argjson advisories "$(cat tmp/new_advisories.json)" --arg now "$NOW" '{
version: "1.0.0",
updated: $now,
description: "Community-driven security advisory feed for ClawSec",
advisories: ($advisories | sort_by(.published) | reverse)
}' > tmp/updated_feed.json
fi
# Validate JSON
if jq empty tmp/updated_feed.json 2>/dev/null; then
echo "Feed JSON is valid"
mv tmp/updated_feed.json "$FEED_PATH"
# Also update the skill feed
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
cp "$FEED_PATH" "$SKILL_FEED_PATH"
echo "=== Updated feeds ==="
echo "Main feed: $FEED_PATH"
echo "Skill feed: $SKILL_FEED_PATH"
jq '.advisories | length' "$FEED_PATH"
else
echo "Error: Generated invalid JSON"
exit 1
fi
- name: Create Pull Request
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
id: create-pr
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: automated/nvd-cve-update-${{ github.run_id }}
delete-branch: true
title: "chore: CVE advisories - ${{ steps.transform.outputs.new_count }} new, ${{ steps.updates.outputs.update_count }} updated"
body: |
## Summary
Automated update from NVD CVE feed.
- **New advisories:** ${{ steps.transform.outputs.new_count }}
- **Updated advisories:** ${{ steps.updates.outputs.update_count }}
- **Poll window:** ${{ steps.dates.outputs.start_date }} → ${{ steps.dates.outputs.end_date }}
- **Keywords:** ${{ env.KEYWORDS }}
---
*This PR was automatically generated by the NVD CVE polling workflow.*
commit-message: |
chore: CVE advisories - ${{ steps.transform.outputs.new_count }} new, ${{ steps.updates.outputs.update_count }} updated
Automated update from NVD CVE feed.
Keywords: ${{ env.KEYWORDS }}
Poll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}
add-paths: |
${{ env.FEED_PATH }}
${{ env.SKILL_FEED_PATH }}
- name: Summary
run: |
echo "## NVD CVE Poll Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Poll Window | ${{ steps.dates.outputs.start_date }} → ${{ steps.dates.outputs.end_date }} |" >> $GITHUB_STEP_SUMMARY
echo "| Keywords | $KEYWORDS |" >> $GITHUB_STEP_SUMMARY
echo "| CVEs Found (filtered) | ${{ steps.process.outputs.filtered_count }} |" >> $GITHUB_STEP_SUMMARY
echo "| New Advisories | ${{ steps.transform.outputs.new_count }} |" >> $GITHUB_STEP_SUMMARY
echo "| Updated Advisories | ${{ steps.updates.outputs.update_count }} |" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.transform.outputs.new_count }}" != "0" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### New Advisories" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
jq -r '.[] | "- **\(.id)** (\(.severity)): \(.title)"' tmp/new_advisories.json >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ steps.updates.outputs.update_count }}" != "0" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Updated Advisories" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
jq -r '.[] | "- **\(.id)**: \(.changes | join(", "))"' tmp/updated_advisories.json >> $GITHUB_STEP_SUMMARY
fi
if [ "${{ steps.transform.outputs.new_count }}" != "0" ] || [ "${{ steps.updates.outputs.update_count }}" != "0" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "🔀 Created PR: ${{ steps.create-pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ No new or updated CVEs found." >> $GITHUB_STEP_SUMMARY
fi
+434
View File
@@ -0,0 +1,434 @@
name: Skill Release
on:
push:
tags:
- '*-v[0-9]*.[0-9]*.[0-9]*'
permissions:
contents: write
pages: write
id-token: write
concurrency:
group: skill-release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Parse tag
id: parse
run: |
TAG="${{ github.ref_name }}"
# Extract skill name (everything before -v)
SKILL_NAME="${TAG%-v*}"
# Extract version (everything after -v)
VERSION="${TAG#*-v}"
echo "skill_name=${SKILL_NAME}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "skill_path=skills/${SKILL_NAME}" >> $GITHUB_OUTPUT
echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}"
- name: Checkout
uses: actions/checkout@v4
- name: Validate skill exists
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
if [ ! -d "$SKILL_PATH" ]; then
echo "Error: Skill directory not found: $SKILL_PATH"
exit 1
fi
if [ ! -f "$SKILL_PATH/skill.json" ]; then
echo "Error: skill.json not found in $SKILL_PATH"
exit 1
fi
echo "Skill validated: $SKILL_PATH"
- name: Validate version match
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
TAG_VERSION="${{ steps.parse.outputs.version }}"
# Extract version from skill.json
JSON_VERSION=$(jq -r '.version' "$SKILL_PATH/skill.json")
if [ "$TAG_VERSION" != "$JSON_VERSION" ]; then
echo "::error::Version mismatch! Tag version ($TAG_VERSION) != skill.json version ($JSON_VERSION)"
echo "Please ensure the version in $SKILL_PATH/skill.json matches your tag."
exit 1
fi
echo "Version validated: $TAG_VERSION"
- name: Validate SKILL.md frontmatter version
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
TAG_VERSION="${{ steps.parse.outputs.version }}"
# Check if SKILL.md exists
if [ -f "$SKILL_PATH/SKILL.md" ]; then
# Extract version from YAML frontmatter
MD_VERSION=$(grep -m 1 "^version:" "$SKILL_PATH/SKILL.md" | sed 's/version: *//' | tr -d '\r')
if [ -z "$MD_VERSION" ]; then
echo "::warning::No version found in $SKILL_PATH/SKILL.md frontmatter"
elif [ "$TAG_VERSION" != "$MD_VERSION" ]; then
echo "::error::Version mismatch! Tag version ($TAG_VERSION) != SKILL.md version ($MD_VERSION)"
echo "Please ensure the version in $SKILL_PATH/SKILL.md frontmatter matches your tag."
exit 1
else
echo "SKILL.md version validated: $MD_VERSION"
fi
else
echo "No SKILL.md found, skipping frontmatter validation"
fi
- name: Detect publishability
id: publishable
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_PATH/skill.json")
PUBLISHABLE=true
if [ "$INTERNAL" = "true" ]; then
PUBLISHABLE=false
echo "Skill marked internal=true; will skip ClawHub publish."
fi
echo "internal=${INTERNAL}" >> $GITHUB_OUTPUT
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install clawhub CLI
if: steps.publishable.outputs.publishable == 'true'
run: npm install -g clawhub
- name: Login to ClawHub
if: steps.publishable.outputs.publishable == 'true' && secrets.CLAWHUB_TOKEN != ''
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
CLAWHUB_SITE: ${{ vars.CLAWHUB_SITE }}
CLAWHUB_REGISTRY: ${{ vars.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
REGISTRY=${CLAWHUB_REGISTRY:-$SITE}
export CLAWHUB_CONFIG_PATH="$HOME/.clawhub-ci/config.json"
mkdir -p "$(dirname "$CLAWHUB_CONFIG_PATH")"
CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
clawhub login --token "$CLAWHUB_TOKEN" --site "$SITE" --no-input
- name: Generate checksums from SBOM
id: checksums
run: |
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
VERSION="${{ steps.parse.outputs.version }}"
mkdir -p dist
# Start checksums JSON
cat > "dist/checksums.json" << EOF
{
"skill": "${SKILL_NAME}",
"version": "${VERSION}",
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"repository": "${{ github.repository }}",
"tag": "${{ github.ref_name }}",
"files": {
EOF
# Read SBOM files and generate checksums
FIRST=true
TEMPFILE=$(mktemp)
# Get files from SBOM
jq -r '.sbom.files[].path' "$SKILL_PATH/skill.json" > "$TEMPFILE"
while IFS= read -r file; do
FULL_PATH="$SKILL_PATH/$file"
if [ -f "$FULL_PATH" ]; then
SHA256=$(sha256sum "$FULL_PATH" | awk '{print $1}')
SIZE=$(stat -c%s "$FULL_PATH" 2>/dev/null || stat -f%z "$FULL_PATH")
FILENAME=$(basename "$file")
if [ "$FIRST" = true ]; then
FIRST=false
else
echo " ," >> "dist/checksums.json"
fi
cat >> "dist/checksums.json" << FILEENTRY
"${FILENAME}": {
"sha256": "${SHA256}",
"size": ${SIZE},
"path": "${file}",
"url": "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${FILENAME}"
}
FILEENTRY
else
echo "Warning: File not found: $FULL_PATH"
fi
done < "$TEMPFILE"
# Also add skill.json checksum
SKILL_JSON_SHA=$(sha256sum "$SKILL_PATH/skill.json" | awk '{print $1}')
SKILL_JSON_SIZE=$(stat -c%s "$SKILL_PATH/skill.json" 2>/dev/null || stat -f%z "$SKILL_PATH/skill.json")
cat >> "dist/checksums.json" << SKILLJSON
,
"skill.json": {
"sha256": "${SKILL_JSON_SHA}",
"size": ${SKILL_JSON_SIZE},
"url": "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/skill.json"
}
SKILLJSON
# Note: checksums.json is NOT closed here - will be finalized after .skill package is created
echo "=== Intermediate checksums.json (before .skill) ==="
cat "dist/checksums.json"
- name: Bundle security skills into suite
if: steps.parse.outputs.skill_name == 'clawsec-suite'
run: |
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
echo "=== Bundling security skills into suite ==="
# Create bundled directory
mkdir -p "$SKILL_PATH/bundled"
# List of skills to bundle (exclude clawtributor - opt-in only)
BUNDLE_SKILLS=("clawsec-feed" "openclaw-audit-watchdog" "soul-guardian")
for skill in "${BUNDLE_SKILLS[@]}"; do
if [ -d "skills/$skill" ]; then
echo "Bundling $skill..."
mkdir -p "$SKILL_PATH/bundled/$skill"
cp -r "skills/$skill"/* "$SKILL_PATH/bundled/$skill/"
# Verify skill.json exists
if [ -f "$SKILL_PATH/bundled/$skill/skill.json" ]; then
SKILL_VERSION=$(jq -r '.version' "$SKILL_PATH/bundled/$skill/skill.json")
echo "✓ Bundled $skill v${SKILL_VERSION}"
else
echo "ERROR: $skill/skill.json not found"
exit 1
fi
else
echo "WARNING: skills/$skill not found, skipping..."
fi
done
echo "Bundling complete"
- name: Create .skill package
run: |
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
cd "$SKILL_PATH"
# Create zip starting with skill.json
zip -q "../../dist/${SKILL_NAME}.skill" skill.json
# Add each SBOM file individually to preserve directory structure
while IFS= read -r file; do
if [ -f "$file" ]; then
zip -qu "../../dist/${SKILL_NAME}.skill" "$file"
echo "Added: $file"
else
echo "Warning: SBOM file not found: $file"
fi
done < <(jq -r '.sbom.files[].path' skill.json)
# Add README if it exists
if [ -f README.md ]; then
zip -qu "../../dist/${SKILL_NAME}.skill" README.md
echo "Added: README.md"
fi
cd ../..
echo "=== Created ${SKILL_NAME}.skill ==="
unzip -l "dist/${SKILL_NAME}.skill"
- name: Add .skill checksum and finalize checksums.json
run: |
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
SKILL_PACKAGE="dist/${SKILL_NAME}.skill"
# Calculate .skill package checksum
SKILL_PACKAGE_SHA=$(sha256sum "$SKILL_PACKAGE" | awk '{print $1}')
SKILL_PACKAGE_SIZE=$(stat -c%s "$SKILL_PACKAGE" 2>/dev/null || stat -f%z "$SKILL_PACKAGE")
# Add .skill package entry to checksums
cat >> "dist/checksums.json" << SKILLPACKAGE
,
"${SKILL_NAME}.skill": {
"sha256": "${SKILL_PACKAGE_SHA}",
"size": ${SKILL_PACKAGE_SIZE},
"url": "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${SKILL_NAME}.skill"
}
SKILLPACKAGE
# Close JSON
cat >> "dist/checksums.json" << EOF
}
}
EOF
echo "=== Final checksums.json ==="
cat "dist/checksums.json"
- name: Prepare release assets
run: |
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
mkdir -p release-assets
# Copy individual SBOM files
TEMPFILE=$(mktemp)
jq -r '.sbom.files[].path' "$SKILL_PATH/skill.json" > "$TEMPFILE"
while IFS= read -r file; do
if [ -f "$SKILL_PATH/$file" ]; then
# Flatten directory structure for release assets
cp "$SKILL_PATH/$file" "release-assets/$(basename "$file")"
echo "Added: $(basename "$file")"
fi
done < "$TEMPFILE"
# Copy metadata files
cp "$SKILL_PATH/skill.json" release-assets/
# Copy README if exists
if [ -f "$SKILL_PATH/README.md" ]; then
cp "$SKILL_PATH/README.md" release-assets/
fi
# Copy package and checksums
cp "dist/${SKILL_NAME}.skill" release-assets/
cp "dist/checksums.json" release-assets/
echo "=== Release assets ==="
ls -la release-assets/
- name: Publish to ClawHub
if: steps.publishable.outputs.publishable == 'true' && secrets.CLAWHUB_TOKEN != ''
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
CLAWHUB_SITE: ${{ vars.CLAWHUB_SITE }}
CLAWHUB_REGISTRY: ${{ vars.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
SITE=${CLAWHUB_SITE:-https://clawhub.ai}
REGISTRY=${CLAWHUB_REGISTRY:-$SITE}
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
VERSION="${{ steps.parse.outputs.version }}"
NAME=$(jq -r '.name' "$SKILL_PATH/skill.json")
CHANGELOG="Release ${VERSION} via CI"
export CLAWHUB_CONFIG_PATH="$HOME/.clawhub-ci/config.json"
CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
clawhub publish "$SKILL_PATH" \
--slug "$SKILL_NAME" \
--name "$NAME" \
--version "$VERSION" \
--changelog "$CHANGELOG" \
--tags "latest" \
--no-input
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
tag_name: ${{ github.ref_name }}
files: release-assets/*
body: |
## ${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}
### Quick Install
Download the complete skill package:
```bash
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}.skill
```
Or fetch the main skill file directly:
```bash
curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/SKILL.md
```
### Verification
All files include SHA256 checksums. Download `checksums.json` and verify:
```bash
curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json | jq .
```
Verify a file:
```bash
sha256sum SKILL.md
# Compare with value in checksums.json
```
### Files
See `checksums.json` for the complete file manifest with SHA256 hashes.
---
*Released by ClawSec skill distribution pipeline*
draft: false
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Delete superseded releases
run: |
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
CURRENT_VERSION="${{ steps.parse.outputs.version }}"
# Extract major version from current release
CURRENT_MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1)
echo "Current release: $SKILL_NAME v$CURRENT_VERSION (major: $CURRENT_MAJOR)"
# List all releases for this skill
gh release list --limit 100 | grep "^${SKILL_NAME} " | while read -r line; do
# Extract tag from release list (3rd tab-delimited column)
TAG=$(echo "$line" | awk -F'\t' '{print $3}')
VERSION="${TAG#${SKILL_NAME}-v}"
# Skip current version
if [ "$VERSION" = "$CURRENT_VERSION" ]; then
continue
fi
# Extract major version
RELEASE_MAJOR=$(echo "$VERSION" | cut -d. -f1)
# Only delete if same major version (preserve old majors for backwards compat)
if [ "$RELEASE_MAJOR" = "$CURRENT_MAJOR" ]; then
echo "Deleting $TAG (superseded by v$CURRENT_VERSION)"
gh release delete "$TAG" --yes || echo "Warning: Could not delete $TAG"
else
echo "Keeping $TAG as latest for major version $RELEASE_MAJOR"
fi
done
echo "Superseded release cleanup complete"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}