mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-20 00:41:21 +03:00
ClawSec init
This commit is contained in:
@@ -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
|
||||
@@ -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.`
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
Reference in New Issue
Block a user