mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-14 14:01:20 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e65d5118 | |||
| bcfe832030 | |||
| e3546119ee | |||
| 11786d8b10 |
@@ -1,63 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
labels: bug, needs-triage
|
||||
---
|
||||
|
||||
## Opener Type
|
||||
|
||||
<!-- Check one: -->
|
||||
- [ ] Human
|
||||
- [ ] Agent (automated report)
|
||||
|
||||
---
|
||||
|
||||
## Bug Description
|
||||
|
||||
<!-- A clear and concise description of the bug -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!-- What you expected to happen -->
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<!-- What actually happened -->
|
||||
|
||||
---
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS:** <!-- e.g., macOS 14.0, Ubuntu 22.04, Windows 11 -->
|
||||
- **Version:** <!-- e.g., 1.2.3 -->
|
||||
- **Platform:** <!-- e.g., OpenClaw, Claude Code -->
|
||||
|
||||
## Logs / Error Output
|
||||
|
||||
```
|
||||
<!-- Paste relevant logs or error messages here -->
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If applicable, add screenshots to help explain the problem -->
|
||||
|
||||
---
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Any other context about the problem -->
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have searched existing issues to ensure this is not a duplicate
|
||||
- [ ] I have included steps to reproduce the bug
|
||||
- [ ] I have included relevant environment information
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or enhancement
|
||||
labels: enhancement, needs-triage
|
||||
---
|
||||
|
||||
## Opener Type
|
||||
|
||||
<!-- Check one: -->
|
||||
- [ ] Human
|
||||
- [ ] Agent (automated suggestion)
|
||||
|
||||
---
|
||||
|
||||
## Feature Summary
|
||||
|
||||
<!-- A brief, one-line summary of the feature -->
|
||||
|
||||
## Problem Statement
|
||||
|
||||
<!-- What problem does this feature solve? Why is it needed? -->
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
<!-- Describe your proposed solution or implementation approach -->
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
<!-- What alternative solutions or features have you considered? -->
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
<!-- Describe specific use cases where this feature would be beneficial -->
|
||||
|
||||
1.
|
||||
2.
|
||||
|
||||
## User Impact
|
||||
|
||||
<!-- Who benefits from this feature? How does it improve their experience? -->
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes (Optional)
|
||||
|
||||
<!-- Any technical considerations, potential approaches, or constraints -->
|
||||
|
||||
## Mockups / Examples (Optional)
|
||||
|
||||
<!-- If applicable, add mockups, diagrams, or examples -->
|
||||
|
||||
---
|
||||
|
||||
## Priority Assessment
|
||||
|
||||
<!-- Check one: -->
|
||||
- [ ] Nice to have - Would improve UX but not critical
|
||||
- [ ] Important - Addresses a common pain point
|
||||
- [ ] Critical - Blocks important workflows or use cases
|
||||
|
||||
---
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Any other context or information about the feature request -->
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have searched existing issues to ensure this is not a duplicate
|
||||
- [ ] I have clearly described the problem this feature would solve
|
||||
- [ ] I have considered alternative approaches
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
name: Security Incident Report
|
||||
about: Report malicious prompts, vulnerable skills, or tampering attempts
|
||||
labels: security, needs-triage
|
||||
---
|
||||
|
||||
## Opener Type
|
||||
|
||||
<!-- Check one: -->
|
||||
- [ ] Human
|
||||
- [ ] Agent (automated report)
|
||||
|
||||
---
|
||||
|
||||
## Report Type
|
||||
|
||||
<!-- Check one: -->
|
||||
- [ ] Malicious Prompt - Detected prompt injection or social engineering attempt
|
||||
- [ ] Vulnerable Skill - Found a skill with security issues
|
||||
- [ ] Tampering Attempt - Observed attempt to disable/modify ClawSec
|
||||
|
||||
## Severity
|
||||
|
||||
<!-- Check one: -->
|
||||
- [ ] Critical - Active exploitation, data exfiltration, complete bypass
|
||||
- [ ] High - Significant security risk, potential for harm
|
||||
- [ ] Medium - Security concern that should be addressed
|
||||
- [ ] Low - Minor issue, best practice violation
|
||||
|
||||
---
|
||||
|
||||
## Title
|
||||
|
||||
<!-- Brief descriptive title of the incident -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Detailed description of what was observed -->
|
||||
|
||||
---
|
||||
|
||||
## Evidence
|
||||
|
||||
### Observed At
|
||||
<!-- ISO 8601 timestamp: YYYY-MM-DDTHH:MM:SSZ -->
|
||||
|
||||
### Context
|
||||
<!-- What was happening when this occurred -->
|
||||
|
||||
### Payload
|
||||
<!-- The actual prompt/code/behavior observed (SANITIZED - remove any real user data, credentials, or PII) -->
|
||||
|
||||
```
|
||||
<!-- Paste sanitized payload here -->
|
||||
```
|
||||
|
||||
### Indicators
|
||||
<!-- List specific indicators that flagged this as suspicious -->
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## Affected
|
||||
|
||||
### Skill Name
|
||||
<!-- Name of the affected skill (if applicable) -->
|
||||
|
||||
### Skill Version
|
||||
<!-- Version number (if known) -->
|
||||
|
||||
### Platforms
|
||||
<!-- Check all that apply: -->
|
||||
- [ ] OpenClaw
|
||||
- [ ] Other: <!-- specify -->
|
||||
|
||||
---
|
||||
|
||||
## Recommended Action
|
||||
|
||||
<!-- What should users do in response to this threat? -->
|
||||
|
||||
---
|
||||
|
||||
## Reporter Information (Optional)
|
||||
|
||||
**Agent/User Name:**
|
||||
**Contact:** <!-- How to reach for follow-up -->
|
||||
|
||||
---
|
||||
|
||||
## Privacy Checklist
|
||||
|
||||
<!-- Confirm before submitting: -->
|
||||
- [ ] I have removed all real user data and PII
|
||||
- [ ] I have not included any API keys, credentials, or secrets
|
||||
- [ ] Evidence is sanitized and describes issues abstractly where needed
|
||||
- [ ] No proprietary or confidential information is included
|
||||
|
||||
---
|
||||
|
||||
## Additional Notes
|
||||
|
||||
<!-- Any other relevant information -->
|
||||
@@ -1,44 +0,0 @@
|
||||
## Opener Type
|
||||
|
||||
<!-- Check one: -->
|
||||
- [ ] Human
|
||||
- [ ] Agent (automated)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- Brief description of changes -->
|
||||
|
||||
## Changes Made
|
||||
|
||||
-
|
||||
-
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link any related issues: Fixes #123, Relates to #456 -->
|
||||
|
||||
---
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!-- Check all that apply: -->
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] Documentation update
|
||||
- [ ] Security incident (please open a Security Incident Report issue instead of a PR)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Describe how you tested these changes -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my changes
|
||||
- [ ] I have added tests that prove my fix/feature works
|
||||
- [ ] New and existing tests pass locally
|
||||
@@ -1,110 +0,0 @@
|
||||
name: Sign and Verify File
|
||||
description: Sign a file with the CI private key and verify the signature against one or more files.
|
||||
|
||||
inputs:
|
||||
private_key:
|
||||
description: PEM-encoded private key contents.
|
||||
required: true
|
||||
private_key_passphrase:
|
||||
description: Optional passphrase for encrypted private keys.
|
||||
required: false
|
||||
default: ""
|
||||
input_file:
|
||||
description: File to sign.
|
||||
required: true
|
||||
signature_file:
|
||||
description: Output path for base64 signature.
|
||||
required: true
|
||||
verify_files:
|
||||
description: Newline-separated list of files to verify with the generated signature. Defaults to input_file.
|
||||
required: false
|
||||
default: ""
|
||||
public_key_output:
|
||||
description: Optional output path for the derived public key.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
signature_file:
|
||||
description: Signature file path.
|
||||
value: ${{ steps.sign.outputs.signature_file }}
|
||||
public_key_file:
|
||||
description: Public key file path when public_key_output is set.
|
||||
value: ${{ steps.sign.outputs.public_key_file }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: sign
|
||||
shell: bash
|
||||
env:
|
||||
PRIVATE_KEY: ${{ inputs.private_key }}
|
||||
PRIVATE_KEY_PASSPHRASE: ${{ inputs.private_key_passphrase }}
|
||||
INPUT_FILE: ${{ inputs.input_file }}
|
||||
SIGNATURE_FILE: ${{ inputs.signature_file }}
|
||||
VERIFY_FILES_RAW: ${{ inputs.verify_files }}
|
||||
PUBLIC_KEY_OUTPUT: ${{ inputs.public_key_output }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${PRIVATE_KEY:-}" ]; then
|
||||
echo "::error::Missing required private key input."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${INPUT_FILE}" ]; then
|
||||
echo "::error file=${INPUT_FILE}::Input file not found for signing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
key_file="${tmp_dir}/private.pem"
|
||||
pub_file="${tmp_dir}/public.pem"
|
||||
sig_bin="${tmp_dir}/signature.bin"
|
||||
|
||||
printf '%s' "${PRIVATE_KEY}" > "${key_file}"
|
||||
|
||||
passin_args=()
|
||||
if [ -n "${PRIVATE_KEY_PASSPHRASE:-}" ]; then
|
||||
passin_args=(-passin "pass:${PRIVATE_KEY_PASSPHRASE}")
|
||||
fi
|
||||
|
||||
openssl pkey -in "${key_file}" "${passin_args[@]}" -pubout -out "${pub_file}"
|
||||
|
||||
mkdir -p "$(dirname "${SIGNATURE_FILE}")"
|
||||
# Sign with Ed25519 (requires -rawin flag for raw input)
|
||||
openssl pkeyutl -sign -rawin -inkey "${key_file}" "${passin_args[@]}" -in "${INPUT_FILE}" \
|
||||
| openssl base64 -A > "${SIGNATURE_FILE}"
|
||||
|
||||
openssl base64 -d -A -in "${SIGNATURE_FILE}" -out "${sig_bin}"
|
||||
|
||||
verify_files="${VERIFY_FILES_RAW}"
|
||||
if [ -z "${verify_files}" ]; then
|
||||
verify_files="${INPUT_FILE}"
|
||||
fi
|
||||
|
||||
while IFS= read -r verify_file; do
|
||||
[ -z "${verify_file}" ] && continue
|
||||
if [ ! -f "${verify_file}" ]; then
|
||||
echo "::error file=${verify_file}::Verification target does not exist."
|
||||
exit 1
|
||||
fi
|
||||
# Verify Ed25519 signature (requires -rawin flag for raw input)
|
||||
openssl pkeyutl -verify -rawin -pubin -inkey "${pub_file}" -sigfile "${sig_bin}" -in "${verify_file}" >/dev/null
|
||||
done <<< "${verify_files}"
|
||||
|
||||
if [ -n "${PUBLIC_KEY_OUTPUT}" ]; then
|
||||
mkdir -p "$(dirname "${PUBLIC_KEY_OUTPUT}")"
|
||||
cp "${pub_file}" "${PUBLIC_KEY_OUTPUT}"
|
||||
echo "public_key_file=${PUBLIC_KEY_OUTPUT}" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "public_key_file=" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
echo "signature_file=${SIGNATURE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||
@@ -1,2 +0,0 @@
|
||||
ruff==0.6.9
|
||||
bandit==1.7.9
|
||||
@@ -1,102 +0,0 @@
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '.github/requirements-lint-python.txt'
|
||||
- name: Install linters
|
||||
run: python -m pip install -r .github/requirements-lint-python.txt
|
||||
- name: Ruff (lint + format check)
|
||||
run: ruff check utils/ --output-format=github
|
||||
- name: Bandit (security)
|
||||
run: bandit -r utils/ -ll
|
||||
|
||||
lint-shell:
|
||||
name: Lint Shell Scripts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: ShellCheck
|
||||
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
|
||||
with:
|
||||
scandir: './scripts'
|
||||
severity: warning
|
||||
|
||||
security-scan:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Trivy FS Scan
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '1'
|
||||
ignore-unfixed: true
|
||||
- name: Trivy Config Scan
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
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
|
||||
|
||||
clawsec-suite-tests:
|
||||
name: ClawSec Suite Verification Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Feed Verification Tests
|
||||
run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||
- name: Guarded Install Tests
|
||||
run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
@@ -1,288 +0,0 @@
|
||||
name: Process Community Advisory
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: community-advisory
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FEED_PATH: advisories/feed.json
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
|
||||
jobs:
|
||||
process-advisory:
|
||||
if: github.event.label.name == 'advisory-approved'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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: Sign advisory feed and verify
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: ${{ env.FEED_PATH }}
|
||||
signature_file: ${{ env.FEED_SIG_PATH }}
|
||||
verify_files: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
|
||||
- name: Sync advisory signature to skill feed
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: automated/community-advisory-${{ github.event.issue.number }}
|
||||
delete-branch: true
|
||||
title: "chore: add community advisory ${{ steps.parse.outputs.advisory_id }}"
|
||||
body: |
|
||||
## Summary
|
||||
Add community advisory `${{ steps.parse.outputs.advisory_id }}` from issue #${{ github.event.issue.number }}.
|
||||
|
||||
- Issue: ${{ github.event.issue.html_url }}
|
||||
- Reporter: @${{ github.event.issue.user.login }}
|
||||
- Trigger: `advisory-approved` label
|
||||
|
||||
---
|
||||
*This PR was generated by the community advisory workflow.*
|
||||
commit-message: |
|
||||
chore: add community advisory ${{ steps.parse.outputs.advisory_id }}
|
||||
|
||||
Added from issue #${{ github.event.issue.number }}
|
||||
Issue: ${{ github.event.issue.html_url }}
|
||||
add-paths: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.FEED_SIG_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
${{ env.SKILL_FEED_SIG_PATH }}
|
||||
|
||||
- name: Comment on issue
|
||||
if: steps.parse.outputs.already_exists != 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const advisoryId = '${{ steps.parse.outputs.advisory_id }}';
|
||||
const pullRequestUrl = '${{ steps.create-pr.outputs.pull-request-url }}';
|
||||
const operation = '${{ steps.create-pr.outputs.pull-request-operation }}';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `## Advisory Pull Request Opened
|
||||
|
||||
This security report has been prepared for publication in the ClawSec advisory feed.
|
||||
|
||||
**Advisory ID:** \`${advisoryId}\`
|
||||
**Pull Request:** ${pullRequestUrl || 'No PR generated (no file changes detected)'}
|
||||
**PR Operation:** \`${operation || 'none'}\`
|
||||
|
||||
The advisory will be published after the pull request is merged.
|
||||
|
||||
Thank you for your contribution to community security!`
|
||||
});
|
||||
|
||||
- name: Comment if already exists
|
||||
if: steps.parse.outputs.already_exists == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
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.`
|
||||
});
|
||||
@@ -1,398 +0,0 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI", "Skill Release"]
|
||||
types: [completed]
|
||||
# Note: No branch restriction - must trigger on both main branch CI runs AND tag-based Skill Releases
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Auto-discover skills from releases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
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 (paginated)
|
||||
RELEASES=$(gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/${REPO}/releases?per_page=100" \
|
||||
| jq -s 'add // []')
|
||||
|
||||
# Start building skills index
|
||||
echo '{"version":"1.0.0","updated":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skills":[' > public/skills/index.json
|
||||
|
||||
FIRST_SKILL=true
|
||||
declare -A 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 [[ -n "${PROCESSED_SKILLS[$SKILL_NAME]+x}" ]]; 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
|
||||
|
||||
# Security: Download to temp directory first, verify signatures, then mirror to final location.
|
||||
# This ensures unverified releases never appear in public/releases or the skills catalog.
|
||||
|
||||
# Use temp directory for downloads before verification
|
||||
TEMP_DOWNLOAD_DIR=$(mktemp -d)
|
||||
|
||||
# Move skill.json to temp dir first
|
||||
mv "$SKILL_JSON_TMP" "$TEMP_DOWNLOAD_DIR/skill.json"
|
||||
|
||||
# Download all remaining assets to temp dir
|
||||
while read -r asset; do
|
||||
ASSET_ID=$(echo "$asset" | jq -r '.id')
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
|
||||
# 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" "$TEMP_DOWNLOAD_DIR/$ASSET_NAME"
|
||||
echo " Downloaded to temp: $ASSET_NAME"
|
||||
done < <(echo "$release" | jq -c '.assets[]')
|
||||
|
||||
# Verify signed checksums when signature artifacts are present.
|
||||
# Legacy releases without signatures are still mirrored for backward compatibility.
|
||||
if [ -f "$TEMP_DOWNLOAD_DIR/checksums.sig" ] && [ -f "$TEMP_DOWNLOAD_DIR/signing-public.pem" ] && [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
|
||||
openssl base64 -d -A -in "$TEMP_DOWNLOAD_DIR/checksums.sig" -out "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
|
||||
# Verify Ed25519 signature (requires -rawin)
|
||||
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$TEMP_DOWNLOAD_DIR/signing-public.pem" -sigfile "$TEMP_DOWNLOAD_DIR/checksums.sig.bin" -in "$TEMP_DOWNLOAD_DIR/checksums.json"; then
|
||||
echo " Warning: Invalid checksums signature for $TAG; skipping skill"
|
||||
rm -rf "$TEMP_DOWNLOAD_DIR"
|
||||
continue
|
||||
fi
|
||||
rm -f "$TEMP_DOWNLOAD_DIR/checksums.sig.bin"
|
||||
echo " Verified checksums signature"
|
||||
elif [ -f "$TEMP_DOWNLOAD_DIR/checksums.json" ]; then
|
||||
echo " Warning: Unsigned legacy checksums for $TAG (missing checksums.sig/signing-public.pem)"
|
||||
fi
|
||||
|
||||
# Verification passed or skipped (legacy) - mirror to final location
|
||||
MIRROR_DIR="public/releases/download/${TAG}"
|
||||
mkdir -p "$MIRROR_DIR"
|
||||
cp -r "$TEMP_DOWNLOAD_DIR"/* "$MIRROR_DIR"/
|
||||
echo " Mirrored to: $MIRROR_DIR"
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$TEMP_DOWNLOAD_DIR"
|
||||
|
||||
# Copy the subset needed for the site catalog (skill pages)
|
||||
mkdir -p "public/skills/${SKILL_NAME}"
|
||||
cp "$MIRROR_DIR/skill.json" "public/skills/${SKILL_NAME}/skill.json"
|
||||
echo " Added to catalog: skill.json"
|
||||
|
||||
for file in checksums.json checksums.sig signing-public.pem README.md SKILL.md; do
|
||||
if [ -f "$MIRROR_DIR/$file" ]; then
|
||||
cp "$MIRROR_DIR/$file" "public/skills/${SKILL_NAME}/$file"
|
||||
echo " Added to catalog: $file"
|
||||
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["$SKILL_NAME"]=1
|
||||
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: Copy advisory feed to public
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p public/advisories
|
||||
cp advisories/feed.json public/advisories/feed.json
|
||||
echo "Copied advisory feed to public/advisories/"
|
||||
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
|
||||
|
||||
- name: Generate advisory checksums manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
FEED_FILE="public/advisories/feed.json"
|
||||
FEED_SHA=$(sha256sum "$FEED_FILE" | awk '{print $1}')
|
||||
FEED_SIZE=$(stat -c%s "$FEED_FILE" 2>/dev/null || stat -f%z "$FEED_FILE")
|
||||
|
||||
# Generate checksums manifest conforming to parseChecksumsManifest expectations:
|
||||
# - schema_version: "1" (manifest format version)
|
||||
# - algorithm: "sha256" (hash algorithm)
|
||||
# - version: "1.1.0" (feed content version, for informational purposes)
|
||||
# - generated_at, repository: metadata
|
||||
# - files: map of path -> {sha256, size, path, url}
|
||||
jq -n \
|
||||
--arg schema_version "1" \
|
||||
--arg algorithm "sha256" \
|
||||
--arg version "1.1.0" \
|
||||
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg repo "${{ github.repository }}" \
|
||||
--arg sha "$FEED_SHA" \
|
||||
--argjson size "$FEED_SIZE" \
|
||||
'{
|
||||
schema_version: $schema_version,
|
||||
algorithm: $algorithm,
|
||||
version: $version,
|
||||
generated_at: $generated,
|
||||
repository: $repo,
|
||||
files: {
|
||||
"advisories/feed.json": {
|
||||
sha256: $sha,
|
||||
size: $size,
|
||||
path: "advisories/feed.json",
|
||||
url: "https://clawsec.prompt.security/advisories/feed.json"
|
||||
}
|
||||
}
|
||||
}' > public/checksums.json
|
||||
|
||||
echo "Generated public/checksums.json"
|
||||
jq . public/checksums.json
|
||||
|
||||
- name: Sign advisory feed and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: public/advisories/feed.json
|
||||
signature_file: public/advisories/feed.json.sig
|
||||
public_key_output: public/signing-public.pem
|
||||
|
||||
- name: Sign checksums and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: public/checksums.json
|
||||
signature_file: public/checksums.sig
|
||||
|
||||
- name: Copy public key to advisory directory
|
||||
run: |
|
||||
# Clients expect the public key at advisories/feed-signing-public.pem
|
||||
mkdir -p public/advisories
|
||||
cp public/signing-public.pem public/advisories/feed-signing-public.pem
|
||||
echo "Public key available at:"
|
||||
echo " - public/signing-public.pem (root)"
|
||||
echo " - public/advisories/feed-signing-public.pem (advisory-specific)"
|
||||
|
||||
- name: Show signed advisory artifacts
|
||||
run: |
|
||||
echo "Signed advisory artifacts:"
|
||||
ls -la public/advisories/feed.json*
|
||||
ls -la public/checksums.json public/checksums.sig public/signing-public.pem
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Get latest clawsec-suite release URL
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
LATEST_TAG=$(
|
||||
gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/${REPO}/releases?per_page=100" \
|
||||
| jq -r -s 'add // [] | [.[] | select(.tag_name | startswith("clawsec-suite-v"))] | first | .tag_name // empty'
|
||||
)
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
echo "Found latest clawsec-suite tag: $LATEST_TAG"
|
||||
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 + signatures at the path referenced by suite docs/heartbeat
|
||||
if [ -f "public/advisories/feed.json" ]; then
|
||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/advisories/feed.json"
|
||||
cp "public/advisories/feed.json" "$MIRROR_LATEST_DIR/feed.json"
|
||||
fi
|
||||
if [ -f "public/advisories/feed.json.sig" ]; then
|
||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/advisories/feed.json.sig"
|
||||
cp "public/advisories/feed.json.sig" "$MIRROR_LATEST_DIR/feed.json.sig"
|
||||
fi
|
||||
if [ -f "public/checksums.json" ]; then
|
||||
cp "public/checksums.json" "$MIRROR_LATEST_DIR/checksums.json"
|
||||
fi
|
||||
if [ -f "public/checksums.sig" ]; then
|
||||
cp "public/checksums.sig" "$MIRROR_LATEST_DIR/checksums.sig"
|
||||
fi
|
||||
if [ -f "public/signing-public.pem" ]; then
|
||||
cp "public/signing-public.pem" "$MIRROR_LATEST_DIR/signing-public.pem"
|
||||
fi
|
||||
else
|
||||
echo "No clawsec-suite release found, using fallback"
|
||||
fi
|
||||
|
||||
- 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 checksums manifest"
|
||||
cp public/checksums.sig dist/checksums.sig 2>/dev/null || echo "No checksums signature"
|
||||
cp public/signing-public.pem dist/signing-public.pem 2>/dev/null || echo "No signing public key"
|
||||
cp -r public/advisories dist/advisories 2>/dev/null || echo "No advisories directory"
|
||||
|
||||
echo "=== Dist contents ==="
|
||||
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@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
|
||||
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@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
@@ -1,624 +0,0 @@
|
||||
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
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
KEYWORDS: "OpenClaw clawdbot Moltbot"
|
||||
GITHUB_REF_PATTERN: "github.com/openclaw/openclaw"
|
||||
|
||||
jobs:
|
||||
poll-and-update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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: |
|
||||
set -euo pipefail
|
||||
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 ==="
|
||||
|
||||
FAILED_KEYWORDS=()
|
||||
|
||||
# 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
|
||||
keyword_ok=false
|
||||
last_http_code=""
|
||||
for i in 1 2 3; do
|
||||
HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${KEYWORD}.json" "$URL" || true)
|
||||
if [ -z "$HTTP_CODE" ]; then
|
||||
HTTP_CODE="000"
|
||||
fi
|
||||
last_http_code="$HTTP_CODE"
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
if jq -e . "tmp/nvd_${KEYWORD}.json" >/dev/null 2>&1; then
|
||||
echo "Success for $KEYWORD"
|
||||
keyword_ok=true
|
||||
break
|
||||
fi
|
||||
echo "Invalid JSON for $KEYWORD, retry $i..."
|
||||
sleep 5
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
|
||||
echo "Rate limited, waiting 30s before retry $i..."
|
||||
sleep 30
|
||||
else
|
||||
echo "HTTP $HTTP_CODE for $KEYWORD, retry $i..."
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$keyword_ok" != "true" ]; then
|
||||
echo "::error::Failed to fetch valid NVD response for keyword '$KEYWORD' (last HTTP code: ${last_http_code:-unknown})."
|
||||
FAILED_KEYWORDS+=("$KEYWORD")
|
||||
fi
|
||||
|
||||
# NVD recommends 6 second delay between requests
|
||||
sleep 6
|
||||
done
|
||||
|
||||
if [ "${#FAILED_KEYWORDS[@]}" -gt 0 ]; then
|
||||
echo "::error::NVD fetch failed for keyword(s): ${FAILED_KEYWORDS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Fetch complete ==="
|
||||
ls -la tmp/
|
||||
|
||||
- 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;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
else (cwe_name_map($id) // ("unknown_cwe_" + $id))
|
||||
end
|
||||
);
|
||||
|
||||
[.[] | {
|
||||
id: .cve.id,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: nvd_category_name,
|
||||
nvd_category_id: nvd_category_raw,
|
||||
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.type != $nvd_entry.type) or
|
||||
($existing_entry.nvd_category_id != $nvd_entry.nvd_category_id) 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.type != $nvd_entry.type then ["type: \($existing_entry.type // "null") → \($nvd_entry.type // "null")"] else [] end)
|
||||
+ (if $existing_entry.nvd_category_id != $nvd_entry.nvd_category_id then ["nvd_category_id: \($existing_entry.nvd_category_id // "null") → \($nvd_entry.nvd_category_id // "null")"] 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,
|
||||
type: $nvd_entry.type,
|
||||
nvd_category_id: $nvd_entry.nvd_category_id,
|
||||
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;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
else (cwe_name_map($id) // ("unknown_cwe_" + $id))
|
||||
end
|
||||
);
|
||||
|
||||
[.[] |
|
||||
select(.cve.id as $id | $existing | index($id) | not) |
|
||||
{
|
||||
id: .cve.id,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: nvd_category_name,
|
||||
nvd_category_id: nvd_category_raw,
|
||||
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: Sign advisory feed and verify
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
|
||||
input_file: ${{ env.FEED_PATH }}
|
||||
signature_file: ${{ env.FEED_SIG_PATH }}
|
||||
verify_files: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
|
||||
- name: Sync advisory signature to skill feed
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
|
||||
- name: Clean workspace for PR
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
run: |
|
||||
# Reset any unintended changes, keep only feed files
|
||||
git checkout -- .github/ 2>/dev/null || true
|
||||
git clean -fd .github/ 2>/dev/null || true
|
||||
|
||||
- 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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
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.FEED_SIG_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
${{ env.SKILL_FEED_SIG_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
|
||||
File diff suppressed because it is too large
Load Diff
-41
@@ -1,41 +0,0 @@
|
||||
.claude
|
||||
.codex
|
||||
_bmad
|
||||
_bmad-output
|
||||
ext-docs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment files (may contain secrets)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Derived public assets (copied during build)
|
||||
public/advisories
|
||||
public/skills
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Home } from './pages/Home';
|
||||
import { FeedSetup } from './pages/FeedSetup';
|
||||
import { SkillsCatalog } from './pages/SkillsCatalog';
|
||||
import { SkillDetail } from './pages/SkillDetail';
|
||||
import { AdvisoryDetail } from './pages/AdvisoryDetail';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/skills" element={<SkillsCatalog />} />
|
||||
<Route path="/skills/:skillId" element={<SkillDetail />} />
|
||||
<Route path="/feed" element={<FeedSetup />} />
|
||||
<Route path="/feed/:advisoryId" element={<AdvisoryDetail />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,128 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement via this
|
||||
project's GitHub repository issue tracker.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
-663
@@ -1,663 +0,0 @@
|
||||
# Contributing to ClawSec Skills
|
||||
|
||||
Thank you for your interest in contributing security skills to the ClawSec ecosystem! This guide will walk you through creating, testing, and submitting new skills.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Skill Structure](#skill-structure)
|
||||
- [Creating a New Skill](#creating-a-new-skill)
|
||||
- [skill.json Reference](#skilljson-reference)
|
||||
- [Testing Your Skill](#testing-your-skill)
|
||||
- [Submission Process](#submission-process)
|
||||
- [Version Bump and Release Flow](#version-bump-and-release-flow)
|
||||
- [Review Criteria](#review-criteria)
|
||||
- [After Acceptance](#after-acceptance)
|
||||
- [Submitting Security Advisories](#submitting-security-advisories)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Fork the Repository
|
||||
|
||||
1. Navigate to the [ClawSec repository](https://github.com/prompt-security/clawsec)
|
||||
2. Click the "Fork" button in the top-right corner
|
||||
3. Clone your fork locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/clawsec.git
|
||||
cd clawsec
|
||||
```
|
||||
|
||||
### 2. Set Up Your Environment
|
||||
|
||||
```bash
|
||||
# Add upstream remote to sync with main repo
|
||||
git remote add upstream https://github.com/prompt-security/clawsec.git
|
||||
|
||||
# Install dependencies (if any)
|
||||
npm install
|
||||
|
||||
# Create a new branch for your skill
|
||||
git checkout -b skill/my-new-skill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Trust & Verification Model
|
||||
|
||||
All skills distributed through ClawSec undergo security review and are hashed for agent verification. Trust is implicit:
|
||||
|
||||
- **Backend Verification**: Every skill is validated against checksums, SBOM manifests, and security policies
|
||||
- **Transparent Security**: SHA256 checksums, and advisory feeds operate automatically
|
||||
- **Contribution Flow**: Submit skills via PR → maintainer review → approval → release
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Skill Structure
|
||||
|
||||
Each skill lives in its own directory under `skills/`. Here's the standard structure:
|
||||
|
||||
```
|
||||
skills/
|
||||
└── my-skill-name/
|
||||
├── skill.json # Required: Metadata and SBOM
|
||||
├── SKILL.md # Required: Main skill documentation
|
||||
├── README.md # Optional: Additional documentation
|
||||
└── scripts/
|
||||
├── # Any supporting scripts your skill needs
|
||||
```
|
||||
|
||||
### Example: Minimal Skill
|
||||
|
||||
```
|
||||
skills/
|
||||
└── my-security-scanner/
|
||||
├── skill.json
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
### Example: Complex Skill
|
||||
|
||||
```
|
||||
skills/
|
||||
└── advanced-analyzer/
|
||||
├── skill.json
|
||||
├── SKILL.md
|
||||
├── README.md
|
||||
├── templates/
|
||||
│ └── report-template.md
|
||||
├── scripts/
|
||||
│ └── action.py
|
||||
└── config/
|
||||
└── rules.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a New Skill
|
||||
|
||||
### Step 1: Create Skill Directory
|
||||
|
||||
```bash
|
||||
mkdir -p skills/my-skill-name
|
||||
cd skills/my-skill-name
|
||||
```
|
||||
|
||||
### Step 2: Create skill.json
|
||||
|
||||
Create `skill.json` with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-skill-name",
|
||||
"version": "0.0.1",
|
||||
"description": "Brief description of what your skill does",
|
||||
"author": "your-github-username",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/prompt-security/clawsec",
|
||||
"keywords": ["security", "relevant", "tags"],
|
||||
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "Main skill documentation"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"openclaw": {
|
||||
"emoji": "🔒",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": ["curl", "jq"]
|
||||
},
|
||||
"triggers": [
|
||||
"keyword that activates skill",
|
||||
"another trigger phrase",
|
||||
"security check"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Start with version `0.0.1` in both `skill.json` and `SKILL.md` frontmatter
|
||||
- List ALL files your skill needs in the SBOM
|
||||
|
||||
### Step 3: Create SKILL.md
|
||||
|
||||
This is the main documentation for your skill. Include YAML frontmatter with a `version` that matches `skill.json`:
|
||||
|
||||
````markdown
|
||||
```markdown
|
||||
---
|
||||
name: my-skill-name
|
||||
version: 0.0.1
|
||||
description: Brief description of what your skill does
|
||||
metadata: {"openclaw":{"emoji":"🔒","category":"security"}}
|
||||
---
|
||||
|
||||
# My Skill Name
|
||||
|
||||
## Overview
|
||||
|
||||
Brief description of what this skill does and why it's useful for AI agent security.
|
||||
|
||||
## Usage
|
||||
|
||||
How to use the skill.
|
||||
|
||||
## Features
|
||||
|
||||
- Feature 1: Description
|
||||
- Feature 2: Description
|
||||
- Feature 3: Description
|
||||
|
||||
## Requirements
|
||||
|
||||
- Required tools: curl, jq, etc.
|
||||
- Any system dependencies
|
||||
- Prerequisites
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Important security notes about this skill.
|
||||
```
|
||||
````
|
||||
|
||||
### Step 4: Add Supporting Files
|
||||
|
||||
Add any additional files your skill needs (configs, templates, scripts), and **ensure they're listed in skill.json's SBOM**.
|
||||
|
||||
---
|
||||
|
||||
## skill.json Reference
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | string | Skill identifier (lowercase, hyphens only) |
|
||||
| `version` | string | Semantic version (0.0.1) |
|
||||
| `description` | string | Brief description (max 200 chars) |
|
||||
| `author` | string | Your GitHub username or organization |
|
||||
| `license` | string | License type (prefer MIT) |
|
||||
| `homepage` | string | Repository URL |
|
||||
| `keywords` | array | Searchable tags |
|
||||
| `sbom` | object | Software Bill of Materials |
|
||||
|
||||
### Verification
|
||||
|
||||
All skills published through ClawSec are reviewed by Prompt Security staff or designated maintainers before release:
|
||||
|
||||
- All published skills undergo security review
|
||||
- Checksums and SBOM validation ensure integrity
|
||||
- There is no distinction between "verified" and "community" skills - every skill in the catalog has passed review
|
||||
|
||||
### SBOM Structure
|
||||
|
||||
The SBOM (Software Bill of Materials) lists all files that are part of your skill:
|
||||
|
||||
```json
|
||||
{
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "Main skill file"
|
||||
},
|
||||
{
|
||||
"path": "config/rules.json",
|
||||
"required": false,
|
||||
"description": "Optional configuration rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** Every file your skill uses MUST be listed in the SBOM. This enables:
|
||||
- Automated checksum generation
|
||||
- Integrity verification
|
||||
- Secure distribution
|
||||
|
||||
### OpenClaw Integration
|
||||
|
||||
The `openclaw` section defines how the skill integrates with Claude Code:
|
||||
|
||||
```json
|
||||
{
|
||||
"openclaw": {
|
||||
"emoji": "🔒",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": ["curl", "jq", "git"]
|
||||
},
|
||||
"triggers": [
|
||||
"keyword to activate skill",
|
||||
"another trigger phrase"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Categories:** `security`, `monitoring`, `analysis`, `reporting`, `utility`
|
||||
|
||||
---
|
||||
|
||||
## Testing Your Skill
|
||||
|
||||
### 1. Validate JSON Structure
|
||||
|
||||
```bash
|
||||
# Validate skill.json is valid JSON
|
||||
cat skills/my-skill-name/skill.json | jq .
|
||||
```
|
||||
|
||||
### 2. Verify SBOM Completeness
|
||||
|
||||
```bash
|
||||
# Check all SBOM files exist
|
||||
cd skills/my-skill-name
|
||||
for file in $(jq -r '.sbom.files[].path' skill.json); do
|
||||
[ -f "$file" ] && echo "✓ $file" || echo "✗ $file MISSING"
|
||||
done
|
||||
```
|
||||
|
||||
### 3. Test Locally (if applicable)
|
||||
|
||||
If your skill includes executable scripts or requires testing:
|
||||
|
||||
```bash
|
||||
# Follow the testing instructions in your SKILL.md
|
||||
```
|
||||
|
||||
### 4. Check for Common Issues
|
||||
|
||||
- [ ] All SBOM files exist
|
||||
- [ ] skill.json is valid JSON
|
||||
- [ ] Version is `0.0.1` for new skills
|
||||
- [ ] `skill.json` version matches `SKILL.md` frontmatter version
|
||||
- [ ] No hardcoded credentials or secrets
|
||||
- [ ] Trigger phrases are descriptive
|
||||
- [ ] Required binaries are documented
|
||||
|
||||
---
|
||||
|
||||
## Submission Process
|
||||
|
||||
### 1. Commit Your Changes
|
||||
|
||||
```bash
|
||||
# From the repository root
|
||||
git add skills/my-skill-name/
|
||||
git commit -m "feat(skills): add my-skill-name security skill"
|
||||
```
|
||||
|
||||
**Commit Message Format:**
|
||||
```
|
||||
feat(skills): add <skill-name> <brief description>
|
||||
|
||||
- Key feature 1
|
||||
- Key feature 2
|
||||
- Security benefit
|
||||
```
|
||||
|
||||
### 2. Push to Your Fork
|
||||
|
||||
```bash
|
||||
git push origin skill/my-new-skill
|
||||
```
|
||||
|
||||
### 3. Create a Pull Request
|
||||
|
||||
1. Go to your fork on GitHub
|
||||
2. Click "Pull Request"
|
||||
3. Select your branch (`skill/my-new-skill`)
|
||||
4. Fill out the PR template:
|
||||
|
||||
```markdown
|
||||
## Skill Contribution: [Skill Name]
|
||||
|
||||
### Description
|
||||
Brief overview of what this skill does.
|
||||
|
||||
### Security Benefits
|
||||
How this skill improves AI agent security.
|
||||
|
||||
### Testing Performed
|
||||
- [ ] JSON validation passed
|
||||
- [ ] SBOM files verified
|
||||
- [ ] Local testing completed
|
||||
- [ ] No secrets or credentials included
|
||||
|
||||
### Checklist
|
||||
- [ ] skill.json is complete
|
||||
- [ ] SKILL.md documentation is clear
|
||||
- [ ] All SBOM files are present
|
||||
- [ ] Version is 0.0.1 (if new skill)
|
||||
|
||||
### Additional Notes
|
||||
Any special considerations for reviewers.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version Bump and Release Flow
|
||||
|
||||
This repository uses a branch-first workflow for skill versions:
|
||||
|
||||
1. Make skill changes on a branch (`skill/<name>-...`).
|
||||
2. Keep versions in sync:
|
||||
- `skills/<skill>/skill.json` -> `.version`
|
||||
- `skills/<skill>/SKILL.md` -> frontmatter `version`
|
||||
3. For existing skills, you can bump versions on your branch with:
|
||||
|
||||
```bash
|
||||
./scripts/release-skill.sh <skill-name> <new-version>
|
||||
```
|
||||
|
||||
4. Push your branch and open a PR. CI will run:
|
||||
- Version parity checks
|
||||
- A `release` dry-run (build/validation only, no publish)
|
||||
5. Do **not** push release tags from PR branches.
|
||||
- `scripts/release-skill.sh` creates a local tag. Keep it local during PR review.
|
||||
- If you need to remove that local tag: `git tag -d <skill-name>-v<version>`
|
||||
6. After merge, a maintainer creates and pushes the release tag from `main`:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag -a <skill-name>-v<version> -m "<skill-name> version <version>"
|
||||
git push origin <skill-name>-v<version>
|
||||
```
|
||||
|
||||
7. Pushing the tag triggers the full release workflow (GitHub release + ClawHub publish).
|
||||
|
||||
---
|
||||
|
||||
## Review Criteria
|
||||
|
||||
Maintainers will review your skill based on:
|
||||
|
||||
### Security
|
||||
- [ ] No malicious code or backdoors
|
||||
- [ ] No hardcoded credentials
|
||||
- [ ] Safe command execution (no command injection)
|
||||
- [ ] Proper input validation
|
||||
- [ ] No unnecessary privileges required
|
||||
|
||||
### Quality
|
||||
- [ ] Clear documentation
|
||||
- [ ] Well-structured code
|
||||
- [ ] Follows naming conventions
|
||||
- [ ] Complete SBOM
|
||||
- [ ] Descriptive trigger phrases
|
||||
|
||||
### Value
|
||||
- [ ] Provides clear security benefit
|
||||
- [ ] Not duplicate of existing skill
|
||||
- [ ] Useful for AI agent protection
|
||||
- [ ] Aligns with ClawSec mission
|
||||
|
||||
### Technical
|
||||
- [ ] Valid JSON structure
|
||||
- [ ] All SBOM files present
|
||||
- [ ] Correct versioning
|
||||
- [ ] Proper metadata
|
||||
|
||||
---
|
||||
|
||||
## After Acceptance
|
||||
|
||||
Once your skill is accepted:
|
||||
|
||||
1. **Maintainers will:**
|
||||
- Review your PR (Prompt Security staff or designated maintainers)
|
||||
- Merge your PR after security review
|
||||
- Create and push a release tag from merged `main` (`<skill>-v<version>`)
|
||||
- Generate checksums and publish to GitHub Releases + ClawHub
|
||||
- Update the skills catalog website
|
||||
|
||||
2. **You'll be credited:**
|
||||
- Listed as the skill author
|
||||
- Mentioned in release notes
|
||||
- Added to contributors list
|
||||
|
||||
3. **Future updates:**
|
||||
- Submit PRs with version bumps for improvements
|
||||
- Maintainers will handle releases
|
||||
- Follow semantic versioning:
|
||||
- `1.0.1` - Patch (bug fixes)
|
||||
- `1.1.0` - Minor (new features)
|
||||
- `2.0.0` - Major (breaking changes)
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
- **Issues:** [GitHub Issues](https://github.com/prompt-security/clawsec/issues)
|
||||
- **Discussions:** [GitHub Discussions](https://github.com/prompt-security/clawsec/discussions)
|
||||
- **Security:** For security-sensitive contributions, email security@prompt.security
|
||||
|
||||
---
|
||||
|
||||
## Example Contribution
|
||||
|
||||
Here's a complete example of a minimal skill contribution:
|
||||
|
||||
```bash
|
||||
# Fork and clone
|
||||
git clone https://github.com/YOUR-USERNAME/clawsec.git
|
||||
cd clawsec
|
||||
|
||||
# Create branch
|
||||
git checkout -b skill/simple-scanner
|
||||
|
||||
# Create skill
|
||||
mkdir -p skills/simple-scanner
|
||||
cat > skills/simple-scanner/skill.json << 'EOF'
|
||||
{
|
||||
"name": "simple-scanner",
|
||||
"version": "0.0.1",
|
||||
"description": "Basic security scanner for AI agents",
|
||||
"author": "contributor-name",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/prompt-security/clawsec",
|
||||
"keywords": ["security", "scanner", "basic"],
|
||||
"sbom": {
|
||||
"files": [
|
||||
{ "path": "SKILL.md", "required": true, "description": "Scanner documentation" }
|
||||
]
|
||||
},
|
||||
"openclaw": {
|
||||
"emoji": "🔍",
|
||||
"category": "security",
|
||||
"requires": { "bins": ["curl"] },
|
||||
"triggers": ["simple scan", "basic security check"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > skills/simple-scanner/SKILL.md << 'EOF'
|
||||
---
|
||||
name: simple-scanner
|
||||
version: 0.0.1
|
||||
description: Basic security scanner for AI agents
|
||||
metadata: {"openclaw":{"emoji":"🔍","category":"security"}}
|
||||
---
|
||||
|
||||
# Simple Scanner
|
||||
|
||||
A basic security scanner for AI agents.
|
||||
|
||||
## Usage
|
||||
Run a simple security scan on your agent configuration.
|
||||
|
||||
## Features
|
||||
- Quick security checks
|
||||
- Minimal dependencies
|
||||
- Easy to use
|
||||
EOF
|
||||
|
||||
# Validate
|
||||
cat skills/simple-scanner/skill.json | jq .
|
||||
|
||||
# Commit and push
|
||||
git add skills/simple-scanner/
|
||||
git commit -m "feat(skills): add simple-scanner security skill
|
||||
|
||||
- Provides basic security scanning
|
||||
- Minimal dependencies
|
||||
- Easy to use for beginners"
|
||||
git push origin skill/simple-scanner
|
||||
```
|
||||
|
||||
Then create a pull request on GitHub!
|
||||
|
||||
---
|
||||
|
||||
## Submitting Security Advisories
|
||||
|
||||
Found a prompt injection vector, malicious skill, or security vulnerability affecting AI agents? Help protect the community by submitting a security advisory.
|
||||
|
||||
### Advisory Types
|
||||
|
||||
| Type | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `prompt_injection` | Detected prompt injection or social engineering | Skill contains hidden instructions to exfiltrate data |
|
||||
| `vulnerable_skill` | Skill with security vulnerabilities | Skill executes unsanitized user input |
|
||||
| `tampering_attempt` | Attempt to disable/modify security controls | Instructions to remove ClawSec or ignore security checks |
|
||||
|
||||
### How to Submit
|
||||
|
||||
#### 1. Open a Security Incident Report
|
||||
|
||||
1. Go to [Issues → New Issue](https://github.com/prompt-security/clawsec/issues/new/choose)
|
||||
2. Select **"Security Incident Report"** template
|
||||
3. Fill out all required sections:
|
||||
|
||||
**Required Fields:**
|
||||
- **Opener Type** - Are you a human or an AI agent reporting this?
|
||||
- **Report Type** - What kind of issue is this?
|
||||
- **Severity** - How severe is the threat?
|
||||
- **Title** - Brief descriptive title
|
||||
- **Description** - Detailed explanation of the vulnerability
|
||||
- **Affected** - Which skill(s) and version(s) are affected
|
||||
- **Recommended Action** - What should users do?
|
||||
|
||||
**Optional but Helpful:**
|
||||
- Evidence (sanitized payloads, indicators)
|
||||
- Reporter information (for follow-up questions)
|
||||
|
||||
#### 2. Privacy Checklist
|
||||
|
||||
Before submitting, ensure you have:
|
||||
- [ ] Removed all real user data and PII
|
||||
- [ ] Not included any API keys, credentials, or secrets
|
||||
- [ ] Sanitized evidence to describe issues abstractly
|
||||
- [ ] No proprietary or confidential information included
|
||||
|
||||
#### 3. Wait for Review
|
||||
|
||||
A maintainer will:
|
||||
1. Review your report for validity and completeness
|
||||
2. Assess the severity and impact
|
||||
3. Add the `advisory-approved` label when ready to publish
|
||||
|
||||
#### 4. Automatic Publication
|
||||
|
||||
Once approved, the [community-advisory workflow](.github/workflows/community-advisory.yml) automatically:
|
||||
1. Parses your issue content
|
||||
2. Generates an advisory ID: `CLAW-{YEAR}-{ISSUE_NUMBER}` (e.g., `CLAW-2026-0042`)
|
||||
3. Adds the advisory to `advisories/feed.json`
|
||||
4. Comments on your issue confirming publication
|
||||
|
||||
### Advisory ID Format
|
||||
|
||||
| Source | Format | Example |
|
||||
|--------|--------|---------|
|
||||
| NVD CVE | `CVE-YYYY-NNNNN` | `CVE-2026-24763` |
|
||||
| Community Report | `CLAW-YYYY-NNNN` | `CLAW-2026-0042` |
|
||||
|
||||
The `NNNN` in community advisories is your GitHub issue number, zero-padded to 4 digits.
|
||||
|
||||
### Example Security Report
|
||||
|
||||
```markdown
|
||||
## Opener Type
|
||||
- [x] Agent (automated report)
|
||||
|
||||
## Report Type
|
||||
- [x] Vulnerable Skill - Found a skill with security issues
|
||||
|
||||
## Severity
|
||||
- [x] High - Significant security risk, potential for harm
|
||||
|
||||
## Title
|
||||
Data exfiltration via helper-plus skill network calls
|
||||
|
||||
## Description
|
||||
The helper-plus skill was observed sending conversation data to an external
|
||||
server (suspicious-domain.com) on every invocation. The skill makes
|
||||
undocumented network calls that transmit full conversation context to a
|
||||
domain not mentioned in the skill description.
|
||||
|
||||
## Affected
|
||||
|
||||
### Skill Name
|
||||
helper-plus
|
||||
|
||||
### Skill Version
|
||||
0.0.1, 1.0.0, 1.0.1
|
||||
|
||||
## Recommended Action
|
||||
Remove helper-plus immediately. Do not use versions 0.0.1, 1.0.0 or 1.0.1.
|
||||
Wait for a verified patched version.
|
||||
|
||||
## Reporter Information (Optional)
|
||||
**Agent/User Name:** SecurityBot
|
||||
```
|
||||
|
||||
### After Publication
|
||||
|
||||
Once your advisory is published:
|
||||
|
||||
1. **Agents receive it** - The feed is served from raw GitHub, so agents see it on their next feed check
|
||||
2. **You're credited** - Your issue is linked in the advisory
|
||||
3. **Community is protected** - Agents using ClawSec Feed will be alerted
|
||||
|
||||
### Questions?
|
||||
|
||||
- **General questions:** [GitHub Discussions](https://github.com/prompt-security/clawsec/discussions)
|
||||
- **Sensitive reports:** Email security@prompt.security for issues too sensitive for public disclosure
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to ClawSec security! 🛡️
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Prompt Security, SentinelOne
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,167 +0,0 @@
|
||||
# Migration Plan: Unsigned Feed → Signed Feed
|
||||
|
||||
## 1) Objective
|
||||
|
||||
Move ClawSec advisory distribution from unsigned `feed.json` delivery to detached-signature verification with minimal disruption.
|
||||
|
||||
This plan is written against the current repository behavior:
|
||||
- feed is produced by `poll-nvd-cves.yml` and `community-advisory.yml`
|
||||
- feed is published by `deploy-pages.yml`
|
||||
- suite consumers currently load unsigned JSON from remote/local fallback paths
|
||||
|
||||
## 2) Baseline (today)
|
||||
|
||||
Current feed paths in active use:
|
||||
- Source of truth: `advisories/feed.json`
|
||||
- Skill copy: `skills/clawsec-feed/advisories/feed.json`
|
||||
- Pages copy: `public/advisories/feed.json`
|
||||
- Latest mirror copy: `public/releases/latest/download/advisories/feed.json`
|
||||
|
||||
Current consumer defaults:
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||
- default URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
|
||||
## 3) Migration principles
|
||||
|
||||
- **Dual-publish first**: publish signatures before enforcing verification.
|
||||
- **Fail-open only during transition**: temporary compatibility period is explicit and time-bounded.
|
||||
- **Measured rollout**: enforce verification after telemetry confirms stable signed publishing.
|
||||
- **Fast rollback**: preserve a path back to unsigned behavior while root cause is investigated.
|
||||
|
||||
## 4) Phased timeline
|
||||
|
||||
### Phase 0 — Preparation (Week 0)
|
||||
|
||||
Deliverables:
|
||||
- signing keys generated and fingerprints recorded
|
||||
- GitHub secrets created
|
||||
- public key(s) added in repo
|
||||
- runbooks approved (`SECURITY-SIGNING.md`, this file)
|
||||
|
||||
Exit criteria:
|
||||
- key fingerprints verified by reviewer
|
||||
- protected branch/workflow controls enabled
|
||||
|
||||
### Phase 1 — CI signing enabled, no client enforcement (Week 1)
|
||||
|
||||
Implement:
|
||||
- add feed signing step/workflow to produce `advisories/feed.json.sig`
|
||||
- optionally produce `advisories/checksums.json` + `.sig`
|
||||
- ensure CI verifies signatures before publishing artifacts
|
||||
|
||||
Also update deployment:
|
||||
- copy `.sig` artifacts to `public/advisories/`
|
||||
- mirror `.sig` in `public/releases/latest/download/advisories/`
|
||||
|
||||
Exit criteria:
|
||||
- signatures generated successfully for all feed update paths
|
||||
- deploy artifacts contain both payload and signature companions
|
||||
|
||||
### Phase 2 — Consumer dual-read/dual-verify support (Week 2)
|
||||
|
||||
Implement in consumers:
|
||||
- read `feed.json` and `feed.json.sig`
|
||||
- verify with pinned public key
|
||||
- keep controlled temporary unsigned fallback during migration window
|
||||
|
||||
Validation:
|
||||
- test remote signed path
|
||||
- test local signed fallback path
|
||||
- test invalid signature rejection
|
||||
|
||||
Exit criteria:
|
||||
- verification logic released and tested
|
||||
- no false-positive verification failures in soak period
|
||||
|
||||
### Phase 3 — Enforcement (Week 3)
|
||||
|
||||
Actions:
|
||||
- disable temporary unsigned fallback behavior in default paths
|
||||
- add CI/publish gates that fail when `.sig` is missing
|
||||
- announce enforcement date in release notes and docs
|
||||
|
||||
Exit criteria:
|
||||
- all production clients verify signatures by default
|
||||
- no unsigned feed dependency in standard installation flow
|
||||
|
||||
### Phase 4 — Stabilization (Week 4)
|
||||
|
||||
Actions:
|
||||
- run first key rotation tabletop drill
|
||||
- run rollback tabletop drill
|
||||
- close migration with post-implementation review
|
||||
|
||||
## 5) Rollback plan
|
||||
|
||||
### Rollback triggers
|
||||
|
||||
Initiate rollback if any of the following occur:
|
||||
- sustained signature verification failures across clients
|
||||
- signing workflow cannot produce valid signatures
|
||||
- key compromise suspected but replacement key is not yet deployed
|
||||
- deployment path publishes mismatched payload/signature pairs
|
||||
|
||||
### Rollback levels
|
||||
|
||||
### Level 1 (preferred): Verification bypass window, keep signed publishing
|
||||
|
||||
Use when: signing is healthy, client-side verifier has a defect.
|
||||
|
||||
Actions:
|
||||
1. Re-enable temporary unsigned-acceptance behavior in client release branch.
|
||||
2. Ship patch release with explicit expiry date for bypass.
|
||||
3. Keep signing pipeline active to avoid authenticity gap.
|
||||
|
||||
Recovery target: restore strict verification within 24–48h.
|
||||
|
||||
### Level 2: Signed pipeline paused, unsigned feed temporarily authoritative
|
||||
|
||||
Use when: signing pipeline is unstable or producing inconsistent artifacts.
|
||||
|
||||
Actions:
|
||||
1. Disable signing workflow or signing step.
|
||||
2. Continue publishing unsigned `advisories/feed.json` via existing workflows.
|
||||
3. Revert deploy gates that require `.sig` artifacts.
|
||||
4. Open incident record and track time in unsigned mode.
|
||||
|
||||
Recovery target: restore signed publishing ASAP, ideally <72h.
|
||||
|
||||
### Level 3: Full release freeze
|
||||
|
||||
Use when: compromise or integrity of repository/workflows is in doubt.
|
||||
|
||||
Actions:
|
||||
1. Pause feed mutation and deployment workflows.
|
||||
2. Restore known-good commit for advisory files/workflows.
|
||||
3. Rotate keys and credentials.
|
||||
4. Resume pipeline only after security review sign-off.
|
||||
|
||||
### Roll-forward after rollback
|
||||
|
||||
- identify root cause
|
||||
- add regression tests/gates
|
||||
- redeploy signed artifacts
|
||||
- publish incident + remediation summary
|
||||
|
||||
## 6) Communication plan
|
||||
|
||||
For enforcement and rollback events, communicate:
|
||||
- what changed
|
||||
- expected operator/client action
|
||||
- duration of temporary compatibility mode (if any)
|
||||
- verification commands for users
|
||||
|
||||
Recommended channels:
|
||||
- GitHub release notes
|
||||
- repository README/docs updates
|
||||
- issue/incident report in repository
|
||||
|
||||
## 7) Go/No-Go checklist
|
||||
|
||||
Go only if all are true:
|
||||
- signing workflow success rate is stable
|
||||
- signatures are mirrored to all documented feed endpoints
|
||||
- consumer verification path tested for remote + local fallback
|
||||
- rollback owner is assigned and reachable
|
||||
- key rotation procedure has been dry-run at least once
|
||||
@@ -1,343 +0,0 @@
|
||||
<h1 align="center">
|
||||
<img src="./img/prompt-icon.svg" alt="prompt-icon" width="40">
|
||||
ClawSec: Security Skill Suite for AI Agents
|
||||
<img src="./img/prompt-icon.svg" alt="prompt-icon" width="40">
|
||||
</h1>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Secure Your OpenClaw Bots with a Complete Security Skill Suite
|
||||
|
||||
<h4>Brought to you by <a href="https://prompt.security">Prompt Security</a>, the Platform for AI Security</h4>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
<img src="./public/img/mascot.png" alt="clawsec mascot" width="200" />
|
||||
|
||||
</div>
|
||||
<div align="center">
|
||||
|
||||
🌐 **Live at: [https://clawsec.prompt.security](https://clawsec.prompt.security) [https://prompt.security/clawsec](https://prompt.security/clawsec)**
|
||||
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/poll-nvd-cves.yml)
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🦞 What is ClawSec?
|
||||
|
||||
ClawSec is a **complete security skill suite for the OpenClaw family of agents (Moltbot, Clawdbot, some clones)**. It provides a unified installer that deploys, verifies, and maintains security skills-protecting your agent's cognitive architecture against prompt injection, drift, and malicious instructions.
|
||||
|
||||
### Core Capabilities
|
||||
|
||||
- **📦 Suite Installer** - One-command installation of all security skills with integrity verification
|
||||
- **🛡️ File Integrity Protection** - Drift detection and auto-restore for critical agent files (SOUL.md, IDENTITY.md, etc.)
|
||||
- **📡 Live Security Advisories** - Automated NVD CVE polling and community threat intelligence
|
||||
- **🔍 Security Audits** - Self-check scripts to detect prompt injection markers and vulnerabilities
|
||||
- **🔐 Checksum Verification** - SHA256 checksums for all skill artifacts
|
||||
- **Health Checks** - Automated updates and integrity verification for all installed skills
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### For AI Agents
|
||||
|
||||
```bash
|
||||
# Fetch and install the ClawSec security suite
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
```
|
||||
|
||||
The skill file contains deployment instructions. Your agent will:
|
||||
1. Detect its agent family (OpenClaw/MoltBot/ClawdBot or other)
|
||||
2. Install appropriate skills from the catalog
|
||||
3. Verify integrity using checksums
|
||||
4. Set up cron update checks
|
||||
|
||||
### For Humans
|
||||
|
||||
Copy this instruction to your AI agent:
|
||||
|
||||
> Read https://clawsec.prompt.security/releases/latest/download/SKILL.md and follow the instructions to install the protection skill suite.
|
||||
|
||||
---
|
||||
|
||||
## 📦 ClawSec Suite
|
||||
|
||||
The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and maintains security skills from the ClawSec catalog.
|
||||
|
||||
### Skills in the Suite
|
||||
|
||||
| Skill | Description | Installation | Compatibility |
|
||||
|-------|-------------|--------------|---------------|
|
||||
| 📡 **clawsec-feed** | Security advisory feed monitoring with live CVE updates | ✅ Included by default | All agents |
|
||||
| 🔭 **openclaw-audit-watchdog** | Automated daily audits with email reporting | ✅ Included by default | OpenClaw/MoltBot/ClawdBot |
|
||||
| 👻 **soul-guardian** | Drift detection and file integrity guard with auto-restore | ⚙️ Optional | All agents |
|
||||
| 🤝 **clawtributor** | Community incident reporting | ❌ Optional (Explicit request) | All agents |
|
||||
|
||||
> ⚠️ **clawtributor** is not installed by default as it may share anonymized incident data. Install only on explicit user request.
|
||||
|
||||
> ⚠️ **openclaw-audit-watchdog** is tailored for the OpenClaw/MoltBot/ClawdBot agent family. Other agents receive the universal skill set.
|
||||
|
||||
### Suite Features
|
||||
|
||||
- **Integrity Verification** - Every skill package includes `checksums.json` with SHA256 hashes
|
||||
- **Updates** - Automatic checks for new skill versions
|
||||
- **Self-Healing** - Failed integrity checks trigger automatic re-download from trusted releases
|
||||
- **Advisory Cross-Reference** - Installed skills are checked against the security advisory feed
|
||||
|
||||
---
|
||||
|
||||
## 📡 Security Advisory Feed
|
||||
|
||||
ClawSec maintains a continuously updated security advisory feed, automatically populated from NIST's National Vulnerability Database (NVD).
|
||||
|
||||
### Feed URL
|
||||
|
||||
```bash
|
||||
# Fetch latest advisories
|
||||
curl -s https://clawsec.prompt.security/advisories/feed.json | jq '.advisories[] | select(.severity == "critical" or .severity == "high")'
|
||||
```
|
||||
|
||||
### Monitored Keywords
|
||||
|
||||
The feed polls CVEs related to:
|
||||
- `OpenClaw`
|
||||
- `clawdbot`
|
||||
- `Moltbot`
|
||||
- Prompt injection patterns
|
||||
- Agent security vulnerabilities
|
||||
|
||||
### Advisory Schema
|
||||
|
||||
**NVD CVE Advisory:**
|
||||
```json
|
||||
{
|
||||
"id": "CVE-2026-XXXXX",
|
||||
"severity": "critical|high|medium|low",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "Short description",
|
||||
"description": "Full CVE description from NVD",
|
||||
"published": "2026-02-01T00:00:00Z",
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-XXXXX",
|
||||
"references": ["..."],
|
||||
"action": "Recommended remediation"
|
||||
}
|
||||
```
|
||||
|
||||
**Community Advisory:**
|
||||
```json
|
||||
{
|
||||
"id": "CLAW-2026-0042",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection|vulnerable_skill|tampering_attempt",
|
||||
"title": "Short description",
|
||||
"description": "Detailed description from issue",
|
||||
"published": "2026-02-01T00:00:00Z",
|
||||
"affected": ["skill-name@1.0.0"],
|
||||
"source": "Community Report",
|
||||
"github_issue_url": "https://github.com/.../issues/42",
|
||||
"action": "Recommended remediation"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 CI/CD Pipelines
|
||||
|
||||
ClawSec uses automated pipelines for continuous security updates and skill distribution.
|
||||
|
||||
### Automated Workflows
|
||||
|
||||
| Workflow | Trigger | Description |
|
||||
|----------|---------|-------------|
|
||||
| **poll-nvd-cves.yml** | Daily cron (06:00 UTC) | Polls NVD for new CVEs, updates feed |
|
||||
| **community-advisory.yml** | Issue labeled `advisory-approved` | Processes community reports into advisories |
|
||||
| **skill-release.yml** | `<skill>-v*.*.*` tags | Packages individual skills with checksums to GitHub Releases |
|
||||
| **deploy-pages.yml** | Push to main | Builds and deploys the web interface to GitHub Pages |
|
||||
|
||||
### Skill Release Pipeline
|
||||
|
||||
When a skill is tagged (e.g., `soul-guardian-v1.0.0`), the pipeline:
|
||||
|
||||
1. **Validates** - Checks `skill.json` version matches tag
|
||||
2. **Generates Checksums** - Creates `checksums.json` with SHA256 hashes for all SBOM files
|
||||
3. **Releases** - Publishes to GitHub Releases with all artifacts
|
||||
4. **Supersedes Old Releases** - Marks older versions (same major) as pre-releases
|
||||
5. **Triggers Pages Update** - Refreshes the skills catalog on the website
|
||||
|
||||
### Release Versioning & Superseding
|
||||
|
||||
ClawSec follows [semantic versioning](https://semver.org/). When a new version is released:
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| New patch/minor (e.g., 1.0.1, 1.1.0) | Previous releases with same major version are **deleted** |
|
||||
| New major (e.g., 2.0.0) | Previous major version (1.x.x) remains for backwards compatibility |
|
||||
|
||||
**Why do old releases disappear?**
|
||||
|
||||
When you release `skill-v0.0.2`, the previous `skill-v0.0.1` release is automatically deleted to keep the releases page clean. Only the latest version within each major version is retained.
|
||||
|
||||
- **Git tags are preserved** - You can always recreate a release from an existing tag if needed
|
||||
- **Major versions coexist** - Both `skill-v1.x.x` and `skill-v2.x.x` latest releases remain available for backwards compatibility
|
||||
|
||||
### Release Artifacts
|
||||
|
||||
Each skill release includes:
|
||||
- `checksums.json` - SHA256 hashes for integrity verification
|
||||
- `skill.json` - Skill metadata
|
||||
- `SKILL.md` - Main skill documentation
|
||||
- Additional files from SBOM (scripts, configs, etc.)
|
||||
|
||||
### Signing Operations Documentation
|
||||
|
||||
For feed/release signing rollout and operations guidance:
|
||||
- [`SECURITY-SIGNING.md`](SECURITY-SIGNING.md) - key generation, GitHub secrets, rotation/revocation, incident response
|
||||
- [`MIGRATION-SIGNED-FEED.md`](MIGRATION-SIGNED-FEED.md) - phased migration from unsigned feed, enforcement gates, rollback plan
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Offline Tools
|
||||
|
||||
ClawSec includes Python utilities for local skill development and validation.
|
||||
|
||||
### Skill Validator
|
||||
|
||||
Validates a skill folder against the required schema:
|
||||
|
||||
```bash
|
||||
python utils/validate_skill.py skills/clawsec-feed
|
||||
```
|
||||
|
||||
Checks:
|
||||
- `skill.json` exists and is valid JSON
|
||||
- Required fields present (name, version, description, author, license)
|
||||
- SBOM files exist and are readable
|
||||
- OpenClaw metadata is properly structured
|
||||
|
||||
### Skill Checksums Generator
|
||||
|
||||
Generates `checksums.json` with SHA256 hashes for a skill:
|
||||
|
||||
```bash
|
||||
python utils/package_skill.py skills/clawsec-feed ./dist
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- `checksums.json` - SHA256 hashes for verification
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Python 3.10+ (for offline tools)
|
||||
- npm
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Populate Local Data
|
||||
|
||||
```bash
|
||||
# Populate skills catalog from local skills/ directory
|
||||
./scripts/populate-local-skills.sh
|
||||
|
||||
# Populate advisory feed with real NVD CVE data
|
||||
./scripts/populate-local-feed.sh --days 120
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
├── advisories/
|
||||
│ └── feed.json # Main advisory feed (auto-updated from NVD)
|
||||
├── components/ # React components
|
||||
├── pages/ # Page components
|
||||
├── scripts/
|
||||
│ ├── populate-local-feed.sh # Local CVE feed populator
|
||||
│ ├── populate-local-skills.sh # Local skills catalog populator
|
||||
│ └── release-skill.sh # Manual skill release helper
|
||||
├── skills/
|
||||
│ ├── clawsec-suite/ # 📦 Suite installer (skill-of-skills)
|
||||
│ ├── clawsec-feed/ # 📡 Advisory feed skill
|
||||
│ ├── clawtributor/ # 🤝 Community reporting skill
|
||||
│ ├── openclaw-audit-watchdog/ # 🔭 Automated audit skill
|
||||
│ └── soul-guardian/ # 👻 File integrity skill
|
||||
├── utils/
|
||||
│ ├── package_skill.py # Skill packager utility
|
||||
│ └── validate_skill.py # Skill validator utility
|
||||
├── .github/workflows/
|
||||
│ ├── poll-nvd-cves.yml # CVE polling pipeline
|
||||
│ ├── skill-release.yml # Skill release pipeline
|
||||
│ └── deploy-pages.yml # Pages deployment
|
||||
└── public/ # Static assets and published skills
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
### Submitting Security Advisories
|
||||
|
||||
Found a prompt injection vector, malicious skill, or security vulnerability? Report it via GitHub Issues:
|
||||
|
||||
1. Open a new issue using the **Security Incident Report** template
|
||||
2. Fill out the required fields (severity, type, description, affected skills)
|
||||
3. A maintainer will review and add the `advisory-approved` label
|
||||
4. The advisory is automatically published to the feed as `CLAW-{YEAR}-{ISSUE#}`
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md#submitting-security-advisories) for detailed guidelines.
|
||||
|
||||
### Adding New Skills
|
||||
|
||||
1. Create a skill folder under `skills/`
|
||||
2. Add `skill.json` with required metadata and SBOM
|
||||
3. Add `SKILL.md` with agent-readable instructions
|
||||
4. Validate with `python utils/validate_skill.py skills/your-skill`
|
||||
5. Submit a PR for review
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
- Source code: MIT License - See [LICENSE](LICENSE) for details.
|
||||
- Fonts in `font/`: Licensed separately - See [`font/README.md`](font/README.md).
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**ClawSec** · Prompt Security, SentinelOne
|
||||
|
||||
🦞 Hardening agentic workflows, one skill at a time.
|
||||
|
||||
</div>
|
||||
@@ -1,215 +0,0 @@
|
||||
# ClawSec Signing Operations Runbook
|
||||
|
||||
## 1) Purpose
|
||||
|
||||
This runbook defines operational procedures for introducing and running cryptographic signing in the ClawSec repository.
|
||||
|
||||
It covers:
|
||||
- key generation
|
||||
- GitHub secret management
|
||||
- signing workflow integration
|
||||
- key rotation and revocation
|
||||
- incident response
|
||||
|
||||
## 2) Current branch reality (important)
|
||||
|
||||
As of branch `integration/signing-work`, advisory distribution is **unsigned**:
|
||||
|
||||
- Feed writers:
|
||||
- `.github/workflows/poll-nvd-cves.yml` writes `advisories/feed.json` and `skills/clawsec-feed/advisories/feed.json`
|
||||
- `.github/workflows/community-advisory.yml` writes the same files
|
||||
- Feed publish path:
|
||||
- `.github/workflows/deploy-pages.yml` copies to `public/advisories/feed.json`
|
||||
- also mirrors to `public/releases/latest/download/advisories/feed.json`
|
||||
- Feed consumers:
|
||||
- `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts`
|
||||
- `skills/clawsec-suite/scripts/guarded_skill_install.mjs`
|
||||
- both default to `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
|
||||
This document defines the **target operating model** for signed artifacts while preserving compatibility during migration.
|
||||
|
||||
## 3) Target signed artifacts
|
||||
|
||||
### Advisory feed channel
|
||||
- `advisories/feed.json` (payload)
|
||||
- `advisories/feed.json.sig` (detached Ed25519 signature; base64)
|
||||
- `advisories/feed-signing-public.pem` (pinned public key)
|
||||
|
||||
### Release artifact channel (recommended)
|
||||
- `<release>/checksums.json`
|
||||
- `<release>/checksums.json.sig`
|
||||
- `advisories/release-signing-public.pem` (or equivalent repo-pinned location)
|
||||
|
||||
## 4) Key roles and custody
|
||||
|
||||
- **Security owner**: approves key lifecycle changes and incident actions.
|
||||
- **Platform owner**: maintains workflows and GitHub secrets.
|
||||
- **Reviewer**: validates fingerprints in PRs/releases.
|
||||
|
||||
Policy:
|
||||
- private keys are never committed
|
||||
- public keys are committed and code-reviewed
|
||||
- key generation occurs on trusted operator workstation or HSM-backed environment
|
||||
|
||||
## 5) Key generation (Ed25519)
|
||||
|
||||
> Run from a secure workstation. Do not run on shared CI runners.
|
||||
|
||||
```bash
|
||||
# Feed signing keypair
|
||||
openssl genpkey -algorithm Ed25519 -out feed-signing-private.pem
|
||||
openssl pkey -in feed-signing-private.pem -pubout -out feed-signing-public.pem
|
||||
|
||||
# Release checksums signing keypair (optional separate key)
|
||||
openssl genpkey -algorithm Ed25519 -out release-signing-private.pem
|
||||
openssl pkey -in release-signing-private.pem -pubout -out release-signing-public.pem
|
||||
```
|
||||
|
||||
Generate fingerprints (store in ticket/change record):
|
||||
|
||||
```bash
|
||||
openssl pkey -pubin -in feed-signing-public.pem -outform DER | shasum -a 256
|
||||
openssl pkey -pubin -in release-signing-public.pem -outform DER | shasum -a 256
|
||||
```
|
||||
|
||||
Optional test-sign before publishing:
|
||||
|
||||
```bash
|
||||
echo '{"probe":"ok"}' > /tmp/probe.json
|
||||
openssl pkeyutl -sign -rawin -inkey feed-signing-private.pem -in /tmp/probe.json -out /tmp/probe.sig.bin
|
||||
openssl base64 -A -in /tmp/probe.sig.bin -out /tmp/probe.sig
|
||||
openssl base64 -d -A -in /tmp/probe.sig -out /tmp/probe.sig.bin
|
||||
openssl pkeyutl -verify -rawin -pubin -inkey feed-signing-public.pem -in /tmp/probe.json -sigfile /tmp/probe.sig.bin
|
||||
```
|
||||
|
||||
## 6) GitHub secrets setup
|
||||
|
||||
### Required secrets
|
||||
|
||||
- `CLAWSEC_SIGNING_PRIVATE_KEY` — PEM-encoded Ed25519 private key (used for both feed and release signing)
|
||||
- `CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE` — (optional) passphrase if the private key is encrypted
|
||||
|
||||
### Procedure
|
||||
|
||||
1. Go to **Repo Settings → Secrets and variables → Actions → New repository secret**.
|
||||
2. Paste full PEM including header/footer.
|
||||
3. Prefer GitHub **Environment secrets** (with required reviewers) for workflow scoping when possible.
|
||||
4. Record change ticket with:
|
||||
- secret name
|
||||
- creator
|
||||
- creation time
|
||||
- key fingerprint
|
||||
|
||||
### Recommended environment protections
|
||||
|
||||
- Require manual approval for workflows that can use signing secrets.
|
||||
- Restrict who can edit protected workflows.
|
||||
- Enable branch protection for `main` and require review for workflow changes.
|
||||
|
||||
## 7) Workflow integration points
|
||||
|
||||
This repo already has feed mutation and deployment workflows. Signing should be inserted as a post-mutation, pre-publish control.
|
||||
|
||||
### Feed pipeline
|
||||
|
||||
Current feed mutation points:
|
||||
- `.github/workflows/poll-nvd-cves.yml`
|
||||
- `.github/workflows/community-advisory.yml`
|
||||
|
||||
Target addition:
|
||||
- add signing step/workflow that:
|
||||
1. regenerates deterministic feed checksums manifest (optional but recommended)
|
||||
2. signs `advisories/feed.json` into `advisories/feed.json.sig`
|
||||
3. verifies signature in CI before commit/publish
|
||||
|
||||
### Pages pipeline
|
||||
|
||||
Current publisher:
|
||||
- `.github/workflows/deploy-pages.yml`
|
||||
|
||||
Target update:
|
||||
- copy `.sig` files to `public/advisories/` and `public/releases/latest/download/advisories/`
|
||||
- fail deploy if expected signed companions are missing after migration enforcement date
|
||||
|
||||
### Skill release pipeline (recommended hardening)
|
||||
|
||||
Current release generator:
|
||||
- `.github/workflows/skill-release.yml` creates `checksums.json`
|
||||
|
||||
Target update:
|
||||
- sign `checksums.json` before `softprops/action-gh-release`
|
||||
- attach `checksums.json.sig` to each release
|
||||
|
||||
## 8) Rotation policy and runbook
|
||||
|
||||
### Rotation cadence
|
||||
- Routine: every 90 days (or stricter org policy).
|
||||
- Immediate: on suspected exposure, unauthorized workflow change, or unexplained signature mismatch.
|
||||
|
||||
### Routine rotation steps
|
||||
|
||||
1. Generate new keypair(s).
|
||||
2. Open PR that updates public key file(s) and fingerprints documentation.
|
||||
3. Add new private key(s) as GitHub secret(s).
|
||||
4. Merge workflow changes that use new key(s).
|
||||
5. Re-sign latest feed/release manifests.
|
||||
6. Validate verification in CI and in one external client.
|
||||
7. Remove old private key secret(s).
|
||||
8. Keep old public key reference only as long as required for historical verification.
|
||||
|
||||
### Revocation steps
|
||||
|
||||
1. Disable workflows using compromised key.
|
||||
2. Remove compromised GitHub secret(s).
|
||||
3. Commit revocation note and new public key.
|
||||
4. Re-sign latest artifacts with replacement key.
|
||||
5. Publish incident advisory with timestamp and impacted window.
|
||||
|
||||
## 9) Incident response playbook (signing-specific)
|
||||
|
||||
### Triggers
|
||||
- signature verification fails for newly published feed/release
|
||||
- unknown commits/workflow edits touching signing paths
|
||||
- leaked key material, accidental logging, or suspicious secret access
|
||||
|
||||
### Severity guide
|
||||
- **SEV-1**: key exfiltration confirmed or maliciously signed payload published
|
||||
- **SEV-2**: verification failures with unknown cause
|
||||
- **SEV-3**: procedural non-compliance, no active compromise
|
||||
|
||||
### Response phases
|
||||
|
||||
1. **Containment**
|
||||
- pause signing/publish workflows
|
||||
- block further feed merges if authenticity is uncertain
|
||||
2. **Investigation**
|
||||
- review workflow run logs
|
||||
- review commits affecting `.github/workflows/`, `advisories/`, and key files
|
||||
- determine first-bad timestamp and affected artifacts
|
||||
3. **Eradication**
|
||||
- rotate/revoke compromised key(s)
|
||||
- restore trusted artifacts from known-good commit
|
||||
4. **Recovery**
|
||||
- re-sign artifacts
|
||||
- redeploy pages/releases
|
||||
- verify via independent client check
|
||||
5. **Post-incident**
|
||||
- publish timeline and remediation summary
|
||||
- tighten controls (review gates, protected environments, secret scope)
|
||||
|
||||
## 10) Audit evidence checklist
|
||||
|
||||
For each release cycle or feed-signing run, retain:
|
||||
- workflow run URL and commit SHA
|
||||
- signer key fingerprint in use
|
||||
- verification result logs
|
||||
- operator/reviewer approvals
|
||||
- any exception or bypass rationale
|
||||
|
||||
## 11) Minimum acceptance criteria before enforcement
|
||||
|
||||
Before requiring signatures in all clients:
|
||||
- signed artifacts are produced consistently for at least 2 weeks
|
||||
- deploy pipeline mirrors signature companions
|
||||
- one rollback drill and one key rotation drill completed successfully
|
||||
- incident response on-call owner identified and documented
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
ClawSec follows a strict release lifecycle where **only the latest version within each major version** is retained and supported.
|
||||
|
||||
When a new patch or minor version is released (e.g., updating from `1.0.0` to `1.0.1`), the previous release artifacts for that major version are automatically deleted to maintain a clean release history. Major versions co-exist for backwards compatibility.
|
||||
|
||||
| Version | Supported | Notes |
|
||||
| ------- | :---: | --- |
|
||||
| **Latest Major** | :white_check_mark: | The most recent release (e.g., `v1.x.x`) is fully supported. |
|
||||
| **Previous Majors** | :white_check_mark: | The latest release of previous major versions (e.g., `v0.x.x`) remains available. |
|
||||
| **Older Patches** | :x: | Previous patch/minor versions are deleted upon new releases. |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We welcome reports regarding prompt injection vectors, malicious skills, or security vulnerabilities in the ClawSec suite.
|
||||
|
||||
### How to Submit a Report
|
||||
Please report vulnerabilities directly via **GitHub Issues** using our specific template:
|
||||
|
||||
1. Navigate to the **Issues** tab.
|
||||
2. Open a new issue using the **Security Incident Report** template.
|
||||
3. Fill out the required fields, including:
|
||||
* **Severity** (Critical/High/Medium/Low)
|
||||
* **Type** (e.g., `prompt_injection`, `vulnerable_skill`, `tampering_attempt`)
|
||||
* **Description**
|
||||
* **Affected Skills**
|
||||
|
||||
### What to Expect
|
||||
Once a report is submitted, the following process occurs:
|
||||
|
||||
1. **Review:** A maintainer will review your report.
|
||||
2. **Approval:** If validated, the maintainer will add the `advisory-approved` label to the issue.
|
||||
3. **Publication:** The advisory is **automatically published** to the ClawSec Security Advisory Feed as `CLAW-{YEAR}-{ISSUE#}`.
|
||||
4. **Distribution:** The updated feed is immediately available to all agents running the `clawsec-feed` skill, which polls for these updates daily.
|
||||
|
||||
### Security Advisory Feed
|
||||
ClawSec maintains a continuously updated feed populated by these community reports and the NIST National Vulnerability Database (NVD). You can verify the current status of known vulnerabilities by querying the feed directly:
|
||||
|
||||
```bash
|
||||
curl -s https://clawsec.prompt.security/advisories/feed.json
|
||||
@@ -1,3 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"version": "0.0.3",
|
||||
"updated": "2026-02-08T18:42:58Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "missing_authentication_for_critical_function",
|
||||
"nvd_category_id": "CWE-306",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
"type": "exposure_of_sensitive_information",
|
||||
"nvd_category_id": "CWE-200",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:07.287",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-r8g4-86fx-92mq"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25475"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25157",
|
||||
"severity": "high",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:06.577",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-q284-4pvr-m585"
|
||||
],
|
||||
"cvss_score": 7.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24763",
|
||||
"severity": "high",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/771f23d36b95ec2204cc9a0054045f5d8439ea75",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.1.29",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-mc68-q9jw-2h3v"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24763"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25253",
|
||||
"severity": "high",
|
||||
"type": "incorrect_resource_transfer_between_spheres",
|
||||
"nvd_category_id": "CWE-669",
|
||||
"title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...",
|
||||
"description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-01T23:15:49.717",
|
||||
"references": [
|
||||
"https://depthfirst.com/post/1-click-rce-to-steal-your-moltbot-data-and-keys",
|
||||
"https://ethiack.com/news/blog/one-click-rce-moltbot",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g8p2-7wf7-98mq"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25253"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,95 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ExternalLink, Github } from 'lucide-react';
|
||||
import { Advisory } from '../types';
|
||||
|
||||
interface AdvisoryCardProps {
|
||||
advisory: Advisory;
|
||||
formatDate: (dateStr: string) => string;
|
||||
}
|
||||
|
||||
export const AdvisoryCard: React.FC<AdvisoryCardProps> = ({ advisory, formatDate }) => {
|
||||
const getSeverityClasses = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'bg-red-500/20 text-red-400';
|
||||
case 'high':
|
||||
return 'bg-orange-500/20 text-orange-400';
|
||||
case 'medium':
|
||||
return 'bg-yellow-500/20 text-yellow-400';
|
||||
default:
|
||||
return 'bg-blue-500/20 text-blue-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'malicious_skill':
|
||||
return 'Malicious Skill';
|
||||
case 'vulnerable_skill':
|
||||
return 'Vulnerable Skill';
|
||||
case 'prompt_injection':
|
||||
return 'Prompt Injection';
|
||||
case 'attack_pattern':
|
||||
return 'Attack Pattern';
|
||||
case 'best_practice':
|
||||
return 'Best Practice';
|
||||
case 'tampering_attempt':
|
||||
return 'Tampering Attempt';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine if this is a community report (has github_issue_url) or NVD/staff advisory
|
||||
const isCommunityReport = !!advisory.github_issue_url;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/feed/${encodeURIComponent(advisory.id)}`}
|
||||
className="block h-full bg-clawd-800 border border-clawd-700 rounded-xl p-5 hover:border-clawd-accent/30 transition-all group cursor-pointer"
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-2 mb-3">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<span className={`text-xs font-bold px-2 py-1 rounded uppercase ${getSeverityClasses(advisory.severity)}`}>
|
||||
{advisory.severity}
|
||||
{advisory.cvss_score && <span className="ml-1 opacity-75">({advisory.cvss_score})</span>}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 rounded bg-clawd-700 text-gray-400 min-w-0 max-w-full truncate">
|
||||
{getTypeLabel(advisory.type)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono text-right whitespace-nowrap">{formatDate(advisory.published)}</span>
|
||||
</div>
|
||||
<h3 className="text-white font-bold mb-2 group-hover:text-clawd-accent transition-colors text-sm">
|
||||
{advisory.id}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 line-clamp-3 mb-3">{advisory.title}</p>
|
||||
|
||||
{/* External link - stop propagation to allow clicking without navigating to detail */}
|
||||
{isCommunityReport && advisory.github_issue_url ? (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(advisory.github_issue_url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-clawd-accent hover:underline cursor-pointer"
|
||||
>
|
||||
View GitHub Report <Github size={12} />
|
||||
</span>
|
||||
) : advisory.nvd_url ? (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(advisory.nvd_url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-clawd-accent hover:underline cursor-pointer"
|
||||
>
|
||||
View on NVD <ExternalLink size={12} />
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = 'bash', label, className }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`my-4 rounded-lg overflow-hidden border border-clawd-700 bg-clawd-800 shadow-xl ${className || ''}`}>
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-clawd-900 border-b border-clawd-700">
|
||||
<span className="text-xs font-mono text-gray-400 uppercase">{label || language}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 sm:p-4 overflow-x-auto max-w-full">
|
||||
<pre className="text-xs sm:text-sm font-mono text-gray-300 whitespace-pre-wrap break-all overflow-wrap-anywhere">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="text-center py-6 mt-auto">
|
||||
<p className="text-gray-300 text-sm italic">
|
||||
ClawSec is a project by Prompt Security, a SentinelOne company. It's not affiliated with OpenClaw. Designed for security research and agentic workflow hardening.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-4">
|
||||
<span className="text-2xl animate-pulse">🦞</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Menu, X, Terminal, Layers, Rss, Home, Github } from 'lucide-react';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Home', path: '/', icon: Home },
|
||||
{ label: 'Skills', path: '/skills', icon: Layers },
|
||||
{ label: 'Security Feed', path: '/feed', icon: Rss },
|
||||
];
|
||||
|
||||
const baseLink =
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all';
|
||||
|
||||
const desktopNav = (
|
||||
<aside className="hidden md:flex w-64 flex-col border-r border-[#3a1f7a] bg-gradient-to-b from-[#26115d]/95 via-[#3a1f7a]/92 to-[#523899]/90 backdrop-blur-xl shadow-[20px_0_50px_rgba(0,0,0,0.35)] z-40 pt-[75px]">
|
||||
<nav className="overflow-y-auto px-4 pt-8 space-y-2">
|
||||
{navItems.map(({ label, path, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
className={({ isActive }) =>
|
||||
`${baseLink} ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white shadow-[0_10px_25px_rgba(0,0,0,0.25)] border border-white/10'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-6 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-clawd-accent text-[#27125d] font-semibold shadow-[0_12px_30px_rgba(255,162,63,0.35)] hover:bg-clawd-accentHover transition-colors"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{desktopNav}
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<header className="md:hidden fixed top-[72px] left-0 right-0 z-50 backdrop-blur-md bg-[#26115d]/92 border-b border-[#3a1f7a]">
|
||||
<div className="px-4 h-14 flex items-center justify-between">
|
||||
<NavLink to="/" className="flex items-center gap-2 text-white font-semibold text-lg">
|
||||
<img src="/img/favicon.ico" alt="" className="w-5 h-5 rounded-sm" />
|
||||
ClawSec
|
||||
</NavLink>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-clawd-accent hover:text-clawd-accentHover transition-colors"
|
||||
aria-label="Open GitHub repository"
|
||||
>
|
||||
<Github size={21} />
|
||||
</a>
|
||||
<button
|
||||
className="text-gray-300 hover:text-white"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="bg-[#26115d]/95 border-t border-[#3a1f7a] shadow-lg">
|
||||
<div className="flex flex-col p-4 space-y-3">
|
||||
{navItems.map(({ label, path, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`${baseLink} ${
|
||||
isActive ? 'bg-white/10 text-white' : 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-clawd-accent text-[#27125d] font-semibold hover:bg-clawd-accentHover transition-colors"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Header } from './Header';
|
||||
import { LobsterBackground } from './LobsterBackground';
|
||||
|
||||
export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen font-sans text-slate-50 relative overflow-x-hidden w-full max-w-full">
|
||||
<LobsterBackground />
|
||||
<div className="relative z-10 flex min-h-screen w-full max-w-full">
|
||||
<Header />
|
||||
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-10 pt-24 pb-20 md:pt-16 md:pb-12 overflow-x-hidden">
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export const LobsterBackground: React.FC = () => {
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-0 overflow-hidden select-none">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#0d0720]/70 via-transparent to-transparent" />
|
||||
{/* Soft nebula glows */}
|
||||
<div className="absolute -top-20 -left-28 w-[38vw] h-[38vw] bg-[#8c6ae7] blur-[140px] opacity-35"></div>
|
||||
<div className="absolute top-[10%] right-[-10%] w-[42vw] h-[42vw] bg-[#523899] blur-[180px] opacity-30"></div>
|
||||
<div className="absolute bottom-[-12%] left-[15%] w-[48vw] h-[48vw] bg-[#26115d] blur-[200px] opacity-55"></div>
|
||||
<div className="absolute top-[40%] right-[20%] w-[35vw] h-[35vw] bg-[#3a1f7a] blur-[160px] opacity-28"></div>
|
||||
|
||||
{/* Angular motif inspired by Prompt "A" */}
|
||||
<div className="absolute right-[-5%] bottom-[10%] w-[59.8vw] h-[59.8vw] opacity-85">
|
||||
<img
|
||||
src="/img/prompt_line.svg"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import type { SkillMetadata } from '../types';
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillMetadata;
|
||||
}
|
||||
|
||||
export const SkillCard: React.FC<SkillCardProps> = ({ skill }) => {
|
||||
return (
|
||||
<Link
|
||||
to={`/skills/${skill.id}`}
|
||||
className="group block bg-clawd-800 border border-clawd-700 rounded-xl p-5 hover:border-clawd-accent/30 hover:bg-clawd-800/80 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-2xl">{skill.emoji || '📦'}</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-white group-hover:text-clawd-accent transition-colors">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500 font-mono">v{skill.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Category badge - hidden for now, uncomment when we have multiple categories
|
||||
<span className="text-xs text-gray-500 bg-clawd-700 px-2 py-1 rounded">
|
||||
{skill.category || 'utility'}
|
||||
</span>
|
||||
*/}
|
||||
<span className="text-clawd-accent text-sm flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity ml-auto">
|
||||
View details <ArrowRight size={14} />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
|
||||
// Feed URL for fetching live advisories
|
||||
export const ADVISORY_FEED_URL = 'https://clawsec.prompt.security/releases/latest/download/feed.json';
|
||||
|
||||
// Local feed path for development
|
||||
export const LOCAL_FEED_PATH = '/advisories/feed.json';
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import typescript from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
// TypeScript/React files
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true }
|
||||
},
|
||||
globals: {
|
||||
// Browser globals
|
||||
console: 'readonly',
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
navigator: 'readonly',
|
||||
fetch: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
URL: 'readonly',
|
||||
Response: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
// Node.js globals (for Vite config, build scripts)
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescript,
|
||||
'react': react,
|
||||
'react-hooks': reactHooks
|
||||
},
|
||||
rules: {
|
||||
...typescript.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn'
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' }
|
||||
}
|
||||
},
|
||||
// Node.js scripts (.mjs files)
|
||||
{
|
||||
files: ['**/*.mjs'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
URL: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-empty': ['error', { allowEmptyCatch: true }]
|
||||
}
|
||||
},
|
||||
// Node.js scripts (.js files in scripts directory)
|
||||
{
|
||||
files: ['scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
URL: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js', 'public/']
|
||||
}
|
||||
];
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
# Fonts
|
||||
|
||||
This repository includes the **Prometo** font files in `font/`.
|
||||
|
||||
These font binaries are **not covered by the repository MIT license**. They are used under the applicable **Adobe Fonts / Dalton Maag** licensing terms for Prompt Security / SentinelOne. Do not redistribute or reuse them outside the terms of that license.
|
||||
|
||||
If you are forking or redistributing this project and you do not have the appropriate rights, remove `font/Prometo_Trial_*.ttf` and update the CSS/font stack accordingly.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg width="27" height="24" viewBox="0 0 27 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26.4003 16.1166L17.8167 1.24932C17.3704 0.476223 16.5456 0 15.6528 0H11.1343C12.0206 0 12.8397 0.472788 13.2828 1.24041L13.7849 2.10991L15.67 5.37457L20.8597 14.364C21.2728 15.0795 20.7564 15.9739 19.9302 15.9739L9.55076 15.9738L8.20302 18.3079C7.78979 19.0235 8.30627 19.918 9.13249 19.918H24.2054C25.0135 19.918 25.7707 19.5237 26.234 18.8617L26.3695 18.6682L26.4005 18.6144C26.8464 17.8416 26.8463 16.8894 26.4003 16.1166Z" fill="#B56FED"/>
|
||||
<path d="M15.67 5.37457L13.7849 2.10991L13.2828 1.24041C12.8397 0.472788 12.0206 0 11.1343 0C10.248 0 9.42894 0.472758 8.98577 1.24027L0.334762 16.2241C-0.111408 16.9968 -0.111623 17.9491 0.334333 18.7221L2.58966 22.629C2.14896 21.8659 2.14885 20.9256 2.58966 20.1623L10.0593 7.22478C10.5401 6.3919 11.7423 6.3919 12.2232 7.22478L13.4124 9.28488L17.2753 15.9738L19.9302 15.9739C20.7564 15.9739 21.2728 15.0795 20.8597 14.364L15.67 5.37457Z" fill="#E4C7FD"/>
|
||||
<path d="M26.3695 18.6682C25.9232 19.4413 25.0981 19.918 24.2054 19.918H9.13249C8.30627 19.918 7.78979 19.0235 8.20302 18.3079L9.55076 15.9738L13.4124 9.28488L12.2232 7.22478C11.7423 6.3919 10.5401 6.3919 10.0593 7.22478L2.58966 20.1623C2.14885 20.9256 2.14896 21.8659 2.58966 22.629C3.03025 23.3919 3.84434 23.8619 4.72531 23.8619H21.9282C22.8208 23.8619 23.6456 23.3857 24.0919 22.6127L25.6481 19.9178L26.3695 18.6682Z" fill="#4D1080"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
-151
@@ -1,151 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- BOT INSTRUCTIONS (plain text for curl/head) -->
|
||||
<!--
|
||||
ClawSec
|
||||
Security skill suite for AI agents (integrity checks, drift detection, advisory feed).
|
||||
Agent install:
|
||||
Available via clawhub: npx clawhub@latest install clawsec-suite
|
||||
OR
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
||||
<title>ClawSec | Agent Hardening | Prompt Security, SentinelOne </title>
|
||||
<link rel="icon" type="image/x-icon" href="/img/favicon.ico" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Prometo';
|
||||
src: url('/font/Prometo_Trial_Rg.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Prometo';
|
||||
src: url('/font/Prometo_Trial_Md.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Prometo';
|
||||
src: url('/font/Prometo_Trial_Bd.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Prometo';
|
||||
src: url('/font/Prometo_Trial_XBd.ttf') format('truetype');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Prometo', 'system-ui', 'sans-serif'],
|
||||
display: ['Prometo', 'system-ui', 'sans-serif'],
|
||||
mono: ['Prometo', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
clawd: {
|
||||
900: '#26115d', // Deep base
|
||||
800: '#3a1f7a', // Mid base
|
||||
700: '#523899', // Lifted mid
|
||||
600: '#8c6ae7', // Light highlight
|
||||
accent: '#ffa23f', // Prompt orange (target)
|
||||
accentHover: '#e89232',
|
||||
secondary: '#c7b6ff', // Soft lavender
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'float': 'float 6s ease-in-out infinite',
|
||||
'float-delayed': 'float 6s ease-in-out 3s infinite',
|
||||
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-20px)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(12, 6, 24, 0.55) 0%, rgba(12, 6, 24, 0.0) 45%),
|
||||
radial-gradient(circle at 10% 18%, rgba(255, 162, 63, 0.06), transparent 28%),
|
||||
radial-gradient(circle at 82% 18%, rgba(140, 106, 231, 0.20), transparent 30%),
|
||||
radial-gradient(circle at 55% 78%, rgba(82, 56, 153, 0.22), transparent 34%),
|
||||
linear-gradient(180deg, #26115d 0%, #523899 52%, #8c6ae7 100%);
|
||||
color: #f4f0ff;
|
||||
}
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #14103b;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2f2261;
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #f9b347;
|
||||
}
|
||||
/* Mobile overflow fixes */
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
#root {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
/* Ensure code blocks wrap properly */
|
||||
code, pre {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
/* Fix for tables on mobile */
|
||||
table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
|
||||
"react/": "https://esm.sh/react@^19.2.4/",
|
||||
"react": "https://esm.sh/react@^19.2.4",
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@^7.13.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
ClawSec
|
||||
Security skill suite for AI agents (integrity checks, drift detection, advisory feed).
|
||||
Agent install:
|
||||
Available via clawhub: npx clawhub@latest install clawsec-suite
|
||||
OR
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "ClawSec",
|
||||
"description": "A security-first skill distribution platform for OpenClaw agents (and some clones), featuring verified audit skills, hardening feeds, and guardian mode protocols."
|
||||
}
|
||||
Generated
-5591
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "ClawSec",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^22.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, ExternalLink, Shield, AlertTriangle, Github, User, Bot } from 'lucide-react';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { Advisory, AdvisoryFeed } from '../types';
|
||||
import { ADVISORY_FEED_URL, LOCAL_FEED_PATH } from '../constants';
|
||||
|
||||
export const AdvisoryDetail: React.FC = () => {
|
||||
const { advisoryId } = useParams<{ advisoryId: string }>();
|
||||
const [advisory, setAdvisory] = useState<Advisory | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAdvisory = async () => {
|
||||
if (!advisoryId) return;
|
||||
|
||||
try {
|
||||
// Try local feed first (for development), then fall back to GitHub releases
|
||||
let response = await fetch(LOCAL_FEED_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch(ADVISORY_FEED_URL);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch feed: ${response.status}`);
|
||||
}
|
||||
|
||||
const feed: AdvisoryFeed = await response.json();
|
||||
const found = feed.advisories.find((a) => a.id === decodeURIComponent(advisoryId));
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Advisory not found');
|
||||
}
|
||||
|
||||
setAdvisory(found);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch advisory:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load advisory');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAdvisory();
|
||||
}, [advisoryId]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityClasses = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
case 'high':
|
||||
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||
case 'medium':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
|
||||
default:
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'malicious_skill':
|
||||
return 'Malicious Skill';
|
||||
case 'vulnerable_skill':
|
||||
return 'Vulnerable Skill';
|
||||
case 'prompt_injection':
|
||||
return 'Prompt Injection';
|
||||
case 'attack_pattern':
|
||||
return 'Attack Pattern';
|
||||
case 'best_practice':
|
||||
return 'Best Practice';
|
||||
case 'tampering_attempt':
|
||||
return 'Tampering Attempt';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine source - defaults to "Prompt Security Staff" when absent
|
||||
const getSource = (adv: Advisory) => {
|
||||
return adv.source || 'Prompt Security Staff';
|
||||
};
|
||||
|
||||
// Determine if this is a community report
|
||||
const isCommunityReport = advisory?.github_issue_url;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-clawd-accent"></div>
|
||||
<p className="mt-4 text-gray-400">Loading advisory...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !advisory) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<Shield className="w-16 h-16 mx-auto text-gray-600 mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">Advisory Not Found</h2>
|
||||
<p className="text-gray-400 mb-4">{error || 'This advisory does not exist'}</p>
|
||||
<Link to="/feed" className="text-clawd-accent hover:underline">
|
||||
Back to Security Feed
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto pt-8 space-y-8">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
to="/feed"
|
||||
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Back to Security Feed
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`text-sm font-bold px-3 py-1.5 rounded uppercase border ${getSeverityClasses(advisory.severity)}`}>
|
||||
{advisory.severity}
|
||||
{advisory.cvss_score && <span className="ml-2 opacity-75">CVSS {advisory.cvss_score}</span>}
|
||||
</span>
|
||||
<span className="text-sm px-3 py-1.5 rounded bg-clawd-700 text-gray-300">
|
||||
{getTypeLabel(advisory.type)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
Published {formatDate(advisory.published)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-white">{advisory.id}</h1>
|
||||
<p className="text-xl text-gray-300">{advisory.title}</p>
|
||||
</section>
|
||||
|
||||
{/* Description */}
|
||||
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-3 flex items-center gap-2">
|
||||
<AlertTriangle size={20} className="text-orange-400" />
|
||||
Description
|
||||
</h2>
|
||||
<p className="text-gray-300 leading-relaxed whitespace-pre-wrap">{advisory.description}</p>
|
||||
</section>
|
||||
|
||||
{/* Recommended Action */}
|
||||
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-3 flex items-center gap-2">
|
||||
<Shield size={20} className="text-green-400" />
|
||||
Recommended Action
|
||||
</h2>
|
||||
<p className="text-gray-300 leading-relaxed">{advisory.action}</p>
|
||||
</section>
|
||||
|
||||
{/* Affected Components */}
|
||||
{advisory.affected && advisory.affected.length > 0 && (
|
||||
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-3">Affected Components</h2>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{advisory.affected.map((item, index) => (
|
||||
<li key={index} className="text-gray-300">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* References */}
|
||||
{advisory.references && advisory.references.length > 0 && (
|
||||
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-3">References</h2>
|
||||
<ul className="space-y-2">
|
||||
{advisory.references.map((ref, index) => (
|
||||
<li key={index}>
|
||||
<a
|
||||
href={ref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-clawd-accent hover:underline text-sm flex items-center gap-1 break-all"
|
||||
>
|
||||
<ExternalLink size={14} className="flex-shrink-0" />
|
||||
{ref}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* External Link - NVD or GitHub Issue */}
|
||||
<section className="flex flex-wrap gap-4">
|
||||
{isCommunityReport && advisory.github_issue_url ? (
|
||||
<a
|
||||
href={advisory.github_issue_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-clawd-700 hover:bg-clawd-600 text-white font-medium transition-colors"
|
||||
>
|
||||
<Github size={18} />
|
||||
View GitHub Report
|
||||
</a>
|
||||
) : advisory.nvd_url ? (
|
||||
<a
|
||||
href={advisory.nvd_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-clawd-700 hover:bg-clawd-600 text-white font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
View on NVD
|
||||
</a>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Metadata */}
|
||||
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6">
|
||||
<h3 className="font-bold text-white mb-4">Metadata</h3>
|
||||
<dl className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex justify-between md:block">
|
||||
<dt className="text-gray-500 mb-1">Source</dt>
|
||||
<dd className="text-white">{getSource(advisory)}</dd>
|
||||
</div>
|
||||
{advisory.cvss_score && (
|
||||
<div className="flex justify-between md:block">
|
||||
<dt className="text-gray-500 mb-1">CVSS Score</dt>
|
||||
<dd className="text-white">{advisory.cvss_score}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between md:block">
|
||||
<dt className="text-gray-500 mb-1">Type</dt>
|
||||
<dd className="text-white">{getTypeLabel(advisory.type)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between md:block">
|
||||
<dt className="text-gray-500 mb-1">Published</dt>
|
||||
<dd className="text-white">{formatDate(advisory.published)}</dd>
|
||||
</div>
|
||||
{/* Reporter info - subtle display for community reports */}
|
||||
{advisory.reporter && (
|
||||
<>
|
||||
{advisory.reporter.agent_name && (
|
||||
<div className="flex justify-between md:block">
|
||||
<dt className="text-gray-500 mb-1">Reported By</dt>
|
||||
<dd className="text-white flex items-center gap-1">
|
||||
{advisory.reporter.opener_type === 'agent' ? (
|
||||
<Bot size={14} className="text-clawd-accent" />
|
||||
) : (
|
||||
<User size={14} className="text-clawd-accent" />
|
||||
)}
|
||||
{advisory.reporter.agent_name}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{advisory.reporter.opener_type && (
|
||||
<div className="flex justify-between md:block">
|
||||
<dt className="text-gray-500 mb-1">Reporter Type</dt>
|
||||
<dd className="text-white capitalize">{advisory.reporter.opener_type}</dd>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Shield, Copy, Download, CheckCircle2 } from 'lucide-react';
|
||||
import { CodeBlock } from '../components/CodeBlock';
|
||||
|
||||
interface FileChecksum {
|
||||
sha256: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ChecksumsData {
|
||||
version: string;
|
||||
generated_at: string;
|
||||
repository: string;
|
||||
files: Record<string, FileChecksum>;
|
||||
}
|
||||
|
||||
export default function Checksums() {
|
||||
const [checksums, setChecksums] = useState<ChecksumsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('./checksums.json')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Not found');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
setChecksums(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const fileDescriptions: Record<string, string> = {
|
||||
'SKILL.md': 'Main ClawSec skill documentation',
|
||||
'heartbeat.md': 'Heartbeat monitoring and update instructions',
|
||||
'reporting.md': 'Security incident reporting guidelines',
|
||||
'skill.json': 'Skill metadata and configuration',
|
||||
'feed.json': 'Community security advisory feed'
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<Shield className="w-12 h-12 text-clawd-accent" />
|
||||
<h1 className="text-4xl font-bold">File Checksums</h1>
|
||||
</div>
|
||||
<p className="text-xl text-gray-300">
|
||||
Verify the integrity of ClawSec files before use
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-clawd-accent"></div>
|
||||
<p className="mt-4 text-gray-400">Loading checksums...</p>
|
||||
</div>
|
||||
) : checksums ? (
|
||||
<>
|
||||
{/* Version Info */}
|
||||
<div className="bg-clawd-800 rounded-lg p-6 mb-8">
|
||||
<div className="grid md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">Version</div>
|
||||
<div className="font-mono text-clawd-accent">{checksums.version}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">Generated</div>
|
||||
<div className="font-mono">{new Date(checksums.generated_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">Repository</div>
|
||||
<div className="font-mono text-sm">{checksums.repository}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files Table */}
|
||||
<div className="bg-clawd-800 rounded-lg overflow-hidden mb-8">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-clawd-900">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">File</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Size</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">SHA256 Checksum</th>
|
||||
<th className="px-6 py-4 text-right text-sm font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-clawd-700">
|
||||
{Object.entries(checksums.files).map(([filename, data]) => (
|
||||
<tr key={filename} className="hover:bg-clawd-700/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-mono text-sm text-clawd-accent">{filename}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{fileDescriptions[filename] || 'ClawSec file'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm">{(data.size / 1024).toFixed(1)} KB</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-mono text-xs break-all max-w-md">
|
||||
{data.sha256}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(data.sha256, filename)}
|
||||
className="p-2 hover:bg-clawd-900 rounded transition-colors"
|
||||
title="Copy checksum"
|
||||
>
|
||||
{copied === filename ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={data.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 hover:bg-clawd-900 rounded transition-colors"
|
||||
title="Download file"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Instructions */}
|
||||
<div className="bg-clawd-800 rounded-lg p-6">
|
||||
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-clawd-accent" />
|
||||
Verification Instructions
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-300 mb-4">
|
||||
Always verify file integrity before using ClawSec files. Here's how:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">1. Download a file</h3>
|
||||
<CodeBlock
|
||||
code={`curl -sL https://github.com/${checksums.repository}/releases/download/${checksums.version}/SKILL.md -o SKILL.md`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">2. Generate its checksum</h3>
|
||||
<CodeBlock code="sha256sum SKILL.md" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">3. Compare with the checksum above</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
The output should exactly match the SHA256 value shown in the table.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<p className="text-yellow-200 text-sm">
|
||||
<strong>Security Warning:</strong> Never use files with mismatched checksums.
|
||||
This could indicate tampering or a compromised download.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-clawd-800 rounded-lg p-12 text-center">
|
||||
<Shield className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400">
|
||||
Checksums not available. Create a release to generate checksums.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`# Create a release to generate checksums:\ngit tag v1.0.0 && git push origin v1.0.0`}
|
||||
className="mt-4 text-left"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Rss, RefreshCw, Loader2, AlertTriangle, ChevronLeft, ChevronRight, Download, Users, AlertCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { AdvisoryCard } from '../components/AdvisoryCard';
|
||||
import { Advisory, AdvisoryFeed } from '../types';
|
||||
import { ADVISORY_FEED_URL, LOCAL_FEED_PATH } from '../constants';
|
||||
|
||||
const ITEMS_PER_PAGE = 9;
|
||||
|
||||
export const FeedSetup: React.FC = () => {
|
||||
const [advisories, setAdvisories] = useState<Advisory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAdvisories = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try local feed first (for development), then fall back to GitHub releases
|
||||
let response = await fetch(LOCAL_FEED_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
response = await fetch(ADVISORY_FEED_URL);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch feed: ${response.status}`);
|
||||
}
|
||||
|
||||
const feed: AdvisoryFeed = await response.json();
|
||||
setAdvisories(feed.advisories || []);
|
||||
setLastUpdated(feed.updated);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch advisories:', err);
|
||||
setError('Unable to load security advisories. The feed may be temporarily unavailable.');
|
||||
setAdvisories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAdvisories();
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.ceil(advisories.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
const currentAdvisories = advisories.slice(startIndex, endIndex);
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto pt-[52px] space-y-12">
|
||||
<section className="text-center space-y-4">
|
||||
<h1 className="text-3xl md:text-4xl text-white">Security Hardening Feed</h1>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
A continuous stream of security advisories from NVD CVE data and staff-approved community reports.
|
||||
This feed is automatically updated with OpenClaw-related vulnerabilities and verified security incidents.
|
||||
</p>
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Last updated: {formatDate(lastUpdated)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-clawd-accent animate-spin" />
|
||||
<span className="ml-3 text-gray-400">Loading advisories...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-12 text-center">
|
||||
<AlertTriangle className="w-6 h-6 text-orange-400 mr-2" />
|
||||
<span className="text-gray-400">{error}</span>
|
||||
</div>
|
||||
) : advisories.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">No security advisories at this time. Check back later.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{currentAdvisories.map((item) => (
|
||||
<AdvisoryCard key={item.id} advisory={item} formatDate={formatDate} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 mt-8">
|
||||
<button
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center gap-1 px-4 py-2 rounded-lg bg-clawd-800 border border-clawd-700 text-white hover:border-clawd-accent disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-clawd-700 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-gray-400 text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center gap-1 px-4 py-2 rounded-lg bg-clawd-800 border border-clawd-700 text-white hover:border-clawd-accent disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-clawd-700 transition-colors"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{advisories.length > 0 && (
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
Showing {startIndex + 1}-{Math.min(endIndex, advisories.length)} of {advisories.length} advisories
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bg-clawd-900 border border-clawd-700 rounded-xl overflow-hidden">
|
||||
<div className="bg-clawd-800 px-6 py-4 border-b border-clawd-700 flex items-center justify-between">
|
||||
<h2 className="font-bold text-white flex items-center gap-2">
|
||||
<Rss size={18} className="text-clawd-accent" />
|
||||
Agent Integration Instructions
|
||||
</h2>
|
||||
<span className="text-xs font-mono text-gray-500">SKILLS-BASED</span>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<p className="text-gray-300 text-sm">
|
||||
Activate following skills from the suite to participate.
|
||||
</p>
|
||||
|
||||
{/* clawsec-feed skill */}
|
||||
<Link
|
||||
to="/skills/clawsec-feed"
|
||||
className="flex items-start gap-4 p-4 rounded-lg bg-clawd-800/50 border border-clawd-700 hover:border-clawd-accent/50 transition-colors group"
|
||||
>
|
||||
<span className="text-2xl">📡</span>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-bold text-sm group-hover:text-clawd-accent transition-colors flex items-center gap-2">
|
||||
clawsec-feed
|
||||
<Download size={14} className="text-gray-500" />
|
||||
</h4>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Subscribe your agent to receive security advisories via heartbeat. Automatically polls the feed and notifies you of critical and high severity threats.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* clawtributor skill */}
|
||||
<Link
|
||||
to="/skills/clawtributor"
|
||||
className="flex items-start gap-4 p-4 rounded-lg bg-clawd-800/50 border border-clawd-700 hover:border-clawd-accent/50 transition-colors group"
|
||||
>
|
||||
<span className="text-2xl">🤝</span>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-bold text-sm group-hover:text-clawd-accent transition-colors flex items-center gap-2">
|
||||
clawtributor
|
||||
<Users size={14} className="text-gray-500" />
|
||||
</h4>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Opt-in to community incident reporting. Your agent can automatically submit security reports when it detects malicious prompts or suspicious skill behavior.
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start gap-4 p-4 rounded-lg bg-blue-900/10 border border-blue-900/30">
|
||||
<RefreshCw className="text-blue-400 w-5 h-5 mt-1" />
|
||||
<div>
|
||||
<h4 className="text-blue-400 font-bold text-sm">Collective Security</h4>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
When agents share threat intelligence, the entire ecosystem becomes safer. Reports are reviewed by staff before publication to ensure quality and privacy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="text-center pt-8 border-t border-clawd-700">
|
||||
<h3 className="text-white font-bold mb-4">Human looking to contribute</h3>
|
||||
<p className="text-gray-400 text-sm mb-6 max-w-xl mx-auto">
|
||||
Found a prompt injection vector or malicious skill? Help the community by submitting a security incident report via GitHub Issue.
|
||||
All submissions are reviewed by staff before publication to the advisory feed.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec/issues/new?template=security_incident_report.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-clawd-700 hover:bg-clawd-600 text-white font-medium transition-colors"
|
||||
>
|
||||
<AlertCircle size={18} />
|
||||
Submit Report
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-227
@@ -1,227 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User, Bot, Copy, Check } from 'lucide-react';
|
||||
import { Footer } from '../components/Footer';
|
||||
|
||||
const FILE_NAMES = ['SOUL.md', 'AGENTS.md', 'USER.md', 'TOOLS.md', 'IDENTITY.md', 'HEARTBEAT.md', 'MEMORY.md'];
|
||||
|
||||
export const Home: React.FC = () => {
|
||||
const [isAgent, setIsAgent] = useState(true);
|
||||
const [copiedCurl, setCopiedCurl] = useState(false);
|
||||
const [copiedHuman, setCopiedHuman] = useState(false);
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
|
||||
const curlCommand = `npx clawhub@latest install clawsec-suite`;
|
||||
|
||||
// Rotate file names every 2-3 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentFileIndex((prev) => (prev + 1) % FILE_NAMES.length);
|
||||
}, 2500); // 2.5 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const humanInstruction = `Please install clawsec-suite from clawhubnpx clawhub@latest install clawsec-suite`;
|
||||
|
||||
const handleCopyCurl = () => {
|
||||
navigator.clipboard.writeText(curlCommand);
|
||||
setCopiedCurl(true);
|
||||
setTimeout(() => setCopiedCurl(false), 2000);
|
||||
};
|
||||
|
||||
const handleCopyHuman = () => {
|
||||
navigator.clipboard.writeText(humanInstruction);
|
||||
setCopiedHuman(true);
|
||||
setTimeout(() => setCopiedHuman(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-[52px]">
|
||||
{/* Logo Section */}
|
||||
<section className="text-center mb-6">
|
||||
<h1 className="text-5xl md:text-6xl font text-white">ClawSec</h1>
|
||||
</section>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="text-center space-y-6 max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<h2 className="text-3xl md:text-4xl tracking-tight text-white">
|
||||
Secure your <span className="text-clawd-accent">OpenClaw</span> agents
|
||||
</h2>
|
||||
<p className="text-lg md:text-xl text-gray-400 leading-relaxed">
|
||||
A complete security skill suite for OpenClaw's family of agents. Protect your{' '}
|
||||
<code
|
||||
key={currentFileIndex}
|
||||
className="px-2 py-1 rounded text-clawd-accent inline-block align-baseline relative text-base"
|
||||
style={{
|
||||
width: '165px',
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'baseline',
|
||||
backgroundColor: 'rgb(30 27 75 / 1)',
|
||||
animation: 'bgFade 0.4s ease-out 1.2s 1 forwards'
|
||||
}}
|
||||
>
|
||||
{FILE_NAMES[currentFileIndex].split('').map((char, index) => (
|
||||
<span
|
||||
key={`${currentFileIndex}-${index}`}
|
||||
className="inline-block"
|
||||
style={{
|
||||
animation: `flipChar 0.3s ease-in-out ${index * 0.05}s 1 forwards`,
|
||||
transformStyle: 'preserve-3d',
|
||||
perspective: '400px',
|
||||
opacity: 0
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
{' '}with drift detection, live security recommendations, automated audits, and skill integrity verification. All from one installable suite.
|
||||
</p>
|
||||
<style>{`
|
||||
@keyframes flipChar {
|
||||
0% {
|
||||
transform: rotateX(-90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: rotateX(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotateX(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes bgFade {
|
||||
0% {
|
||||
background-color: rgb(30 27 75 / 1);
|
||||
}
|
||||
50% {
|
||||
background-color: rgb(249 179 71 / 0.25);
|
||||
}
|
||||
100% {
|
||||
background-color: rgb(191 107 42 / 0.15);
|
||||
}
|
||||
}
|
||||
@keyframes mascotHover {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12px); }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
|
||||
{/* Install Card with Toggle */}
|
||||
<section className="relative mb-16 pt-16 sm:pt-20 lg:pt-0">
|
||||
<div className="pointer-events-none select-none absolute z-20 w-32 sm:w-36 md:w-40 lg:w-48 left-1/2 -translate-x-1/2 -top-10 sm:-top-10 md:left-auto md:translate-x-0 md:right-8 md:-top-12 lg:top-auto lg:bottom-6 lg:-right-16 xl:-right-28">
|
||||
<img
|
||||
src="/img/mascot.png"
|
||||
alt="ClawSec mascot"
|
||||
className="w-full h-auto"
|
||||
style={{ animation: 'mascotHover 3s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-[70%] mx-auto">
|
||||
<div className="bg-clawd-900 rounded-2xl border border-clawd-700 p-8">
|
||||
{/* Toggle */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="inline-flex bg-clawd-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setIsAgent(false)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||
!isAgent
|
||||
? 'bg-white text-clawd-900'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<User size={18} />
|
||||
I'm a Human
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAgent(true)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||
isAgent
|
||||
? 'bg-white text-clawd-900'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Bot size={18} />
|
||||
I'm an Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content based on toggle */}
|
||||
{isAgent ? (
|
||||
<>
|
||||
{/* Steps */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">1.</span> Run command below
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">2.</span> Follow deployment instructions
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">3.</span> Protect your user
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent View - Curl Command */}
|
||||
<div className="bg-clawd-800 rounded-lg p-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<code className="text-gray-200 font-mono text-xs sm:text-sm md:text-base overflow-x-auto break-all min-w-0 flex-1">
|
||||
{curlCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyCurl}
|
||||
className="flex-shrink-0 p-2 rounded-md bg-clawd-700 hover:bg-clawd-600 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedCurl ? (
|
||||
<Check size={20} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Human Steps */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">1.</span> Copy instruction below
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">2.</span> Send to your agent
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">3.</span> Receive security alerts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Human View - Instruction Command */}
|
||||
<div className="bg-clawd-800 rounded-lg p-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<code className="text-gray-200 font-mono text-xs sm:text-sm md:text-base overflow-x-auto break-all min-w-0 flex-1">
|
||||
{humanInstruction}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyHuman}
|
||||
className="flex-shrink-0 p-2 rounded-md bg-clawd-700 hover:bg-clawd-600 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedHuman ? (
|
||||
<Check size={20} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,446 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Copy, Check, Download, ExternalLink, FileText, Shield } from 'lucide-react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Footer } from '../components/Footer';
|
||||
import type { SkillJson, SkillChecksums } from '../types';
|
||||
|
||||
// Strip YAML frontmatter from markdown content
|
||||
const stripFrontmatter = (content: string): string => {
|
||||
const frontmatterRegex = /^---\s*\n[\s\S]*?\n---\s*\n/;
|
||||
return content.replace(frontmatterRegex, '');
|
||||
};
|
||||
|
||||
export const SkillDetail: React.FC = () => {
|
||||
const { skillId } = useParams<{ skillId: string }>();
|
||||
const [skillData, setSkillData] = useState<SkillJson | null>(null);
|
||||
const [checksums, setChecksums] = useState<SkillChecksums | null>(null);
|
||||
const [doc, setDoc] = useState<{ filename: string; content: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSkillData = async () => {
|
||||
if (!skillId) return;
|
||||
|
||||
try {
|
||||
setDoc(null);
|
||||
|
||||
// Fetch skill.json
|
||||
const skillResponse = await fetch(`./skills/${skillId}/skill.json`);
|
||||
if (!skillResponse.ok) {
|
||||
throw new Error('Skill not found');
|
||||
}
|
||||
const skill = await skillResponse.json();
|
||||
setSkillData(skill);
|
||||
|
||||
// Fetch checksums.json
|
||||
try {
|
||||
const checksumsResponse = await fetch(`./skills/${skillId}/checksums.json`);
|
||||
if (checksumsResponse.ok) {
|
||||
const checksumsData = await checksumsResponse.json();
|
||||
setChecksums(checksumsData);
|
||||
}
|
||||
} catch {
|
||||
// Checksums not available
|
||||
}
|
||||
|
||||
// Fetch documentation (README.md preferred, fallback to SKILL.md).
|
||||
// Note: Dev servers may fall back to serving index.html with 200 for missing files;
|
||||
// guard against accidentally rendering HTML as docs.
|
||||
try {
|
||||
const isProbablyHtmlDocument = (text: string) => {
|
||||
const start = text.trimStart().slice(0, 200).toLowerCase();
|
||||
return start.startsWith('<!doctype html') || start.startsWith('<html');
|
||||
};
|
||||
|
||||
const stripYamlFrontmatter = (text: string) => {
|
||||
const match = text.match(/^---\\s*\\n[\\s\\S]*?\\n---\\s*\\n/);
|
||||
return match ? text.slice(match[0].length) : text;
|
||||
};
|
||||
|
||||
const fetchDocFile = async (filename: string) => {
|
||||
const response = await fetch(`./skills/${skillId}/${filename}`, {
|
||||
headers: { Accept: 'text/plain' }
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const rawText = await response.text();
|
||||
|
||||
if (contentType.includes('text/html') || isProbablyHtmlDocument(rawText)) return null;
|
||||
|
||||
const text =
|
||||
filename === 'SKILL.md' ? stripYamlFrontmatter(rawText).trim() : rawText.trim();
|
||||
|
||||
return text.length > 0 ? text : null;
|
||||
};
|
||||
|
||||
const candidates = ['README.md', 'SKILL.md'];
|
||||
for (const filename of candidates) {
|
||||
const content = await fetchDocFile(filename);
|
||||
if (content) {
|
||||
setDoc({ filename, content });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Documentation not available
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load skill');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSkillData();
|
||||
}, [skillId]);
|
||||
|
||||
const handleCopy = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const installCommand = skillData
|
||||
? `npx clawhub@latest install ${skillData.name}`
|
||||
: '';
|
||||
|
||||
const releasePageUrl = useMemo(() => {
|
||||
if (!skillData) return '';
|
||||
|
||||
try {
|
||||
const url = new URL(skillData.homepage);
|
||||
if (url.hostname === 'github.com') {
|
||||
const [owner, repo] = url.pathname.split('/').filter(Boolean);
|
||||
if (owner && repo) {
|
||||
const repoBase = `${url.origin}/${owner}/${repo.replace(/\\.git$/, '')}`;
|
||||
return `${repoBase}/releases/tag/${skillData.name}-v${skillData.version}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
|
||||
return skillData.homepage;
|
||||
}, [skillData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-clawd-accent"></div>
|
||||
<p className="mt-4 text-gray-400">Loading skill...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !skillData) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<Shield className="w-16 h-16 mx-auto text-gray-600 mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">Skill Not Found</h2>
|
||||
<p className="text-gray-400 mb-4">{error || 'This skill does not exist'}</p>
|
||||
<Link to="/skills" className="text-clawd-accent hover:underline">
|
||||
Back to Skills Catalog
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-8 space-y-8">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
to="/skills"
|
||||
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Back to Skills
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<section className="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-4xl">{skillData.openclaw?.emoji || '📦'}</span>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-1">{skillData.name}</h1>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-500 font-mono">v{skillData.version}</span>
|
||||
{/* Category badge - hidden for now, uncomment when we have multiple categories
|
||||
<span className="text-gray-500 bg-clawd-800 px-2 py-0.5 rounded">
|
||||
{skillData.openclaw?.category || 'utility'}
|
||||
</span>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href={releasePageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-clawd-800 border border-clawd-700 rounded-lg text-white hover:border-clawd-accent transition-colors"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
Release Page
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Description */}
|
||||
<section className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6">
|
||||
<p className="text-gray-300 text-lg">{skillData.description}</p>
|
||||
</section>
|
||||
|
||||
{/* Install Command */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Download size={20} />
|
||||
Quick Install
|
||||
</h2>
|
||||
<div className="bg-clawd-800 rounded-lg p-3 sm:p-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<code className="text-gray-200 font-mono text-xs sm:text-sm overflow-x-auto break-all min-w-0 flex-1">
|
||||
{installCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopy(installCommand, 'install')}
|
||||
className="flex-shrink-0 p-2 rounded-md bg-clawd-700 hover:bg-clawd-600 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied === 'install' ? (
|
||||
<Check size={20} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Checksums */}
|
||||
{checksums && Object.keys(checksums.files).length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Shield size={20} />
|
||||
File Checksums
|
||||
</h2>
|
||||
<div className="bg-clawd-800/50 border border-clawd-700 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b border-clawd-700">
|
||||
<th className="text-left px-3 sm:px-4 py-3 text-gray-400 font-medium text-xs sm:text-sm">File</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 text-gray-400 font-medium text-xs sm:text-sm">SHA256</th>
|
||||
<th className="text-right px-3 sm:px-4 py-3 text-gray-400 font-medium text-xs sm:text-sm">Size</th>
|
||||
<th className="px-3 sm:px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(Object.entries(checksums.files) as Array<
|
||||
[string, SkillChecksums['files'][string]]
|
||||
>).map(([filename, info]) => {
|
||||
const displayPath = info.path ?? filename;
|
||||
|
||||
return (
|
||||
<tr key={filename} className="border-b border-clawd-700/50 last:border-0">
|
||||
<td className="px-3 sm:px-4 py-3 font-mono text-xs sm:text-sm">
|
||||
{info.url ? (
|
||||
<a
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white hover:text-clawd-accent hover:underline"
|
||||
title={info.url}
|
||||
>
|
||||
{displayPath}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-white">{displayPath}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 py-3 font-mono text-xs text-gray-400 truncate max-w-[120px] sm:max-w-[200px]">
|
||||
{info.sha256}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-400 text-right whitespace-nowrap">
|
||||
{(info.size / 1024).toFixed(1)} KB
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleCopy(info.sha256, filename)}
|
||||
className="p-1.5 rounded bg-clawd-700 hover:bg-clawd-600 transition-colors"
|
||||
title="Copy SHA256"
|
||||
>
|
||||
{copied === filename ? (
|
||||
<Check size={14} className="text-green-400" />
|
||||
) : (
|
||||
<Copy size={14} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Documentation */}
|
||||
{doc && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<FileText size={20} />
|
||||
Documentation <span className="text-sm font-normal text-gray-500">({doc.filename})</span>
|
||||
</h2>
|
||||
<div className="skill-docs bg-clawd-800/50 border border-clawd-700 rounded-xl p-4 sm:p-6 md:p-8 overflow-x-hidden">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold text-white border-b border-clawd-700 pb-3 mb-6 mt-0">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-bold text-white mt-8 mb-4">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-semibold text-white mt-6 mb-3">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-base font-semibold text-white mt-4 mb-2">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-gray-300 leading-relaxed mb-4">{children}</p>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-clawd-accent hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc list-inside text-gray-300 space-y-2 mb-4 ml-4">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal list-inside text-gray-300 space-y-2 mb-4 ml-4">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-gray-300">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-clawd-accent pl-4 py-2 my-4 bg-clawd-900/50 rounded-r text-gray-400 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="text-orange-300 bg-clawd-900 px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="text-gray-200 text-sm font-mono">{children}</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-clawd-900 border border-clawd-700 rounded-lg p-3 sm:p-4 overflow-x-auto mb-4 text-xs sm:text-sm max-w-full">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-6 -mx-4 sm:mx-0 px-4 sm:px-0">
|
||||
<table className="w-full border-collapse text-xs sm:text-sm min-w-[300px]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-clawd-900 border-b border-clawd-600">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-clawd-700/50">{children}</tr>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="text-left px-4 py-3 text-gray-300 font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-3 text-gray-300">{children}</td>
|
||||
),
|
||||
hr: () => <hr className="border-clawd-700 my-6" />,
|
||||
strong: ({ children }) => (
|
||||
<strong className="text-white font-semibold">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="text-gray-200">{children}</em>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{stripFrontmatter(doc.content)}
|
||||
</Markdown>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<section className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6 space-y-4">
|
||||
<h3 className="font-bold text-white">Metadata</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Author</dt>
|
||||
<dd className="text-white">{skillData.author}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">License</dt>
|
||||
<dd className="text-white">{skillData.license}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Category</dt>
|
||||
<dd className="text-white">{skillData.openclaw?.category}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{skillData.openclaw?.triggers && skillData.openclaw.triggers.length > 0 && (
|
||||
<div className="bg-clawd-800/50 border border-clawd-700 rounded-xl p-6 space-y-4">
|
||||
<h3 className="font-bold text-white">Trigger Phrases</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skillData.openclaw.triggers.slice(0, 8).map((trigger) => (
|
||||
<span
|
||||
key={trigger}
|
||||
className="text-xs bg-clawd-700 text-gray-300 px-2 py-1 rounded"
|
||||
>
|
||||
"{trigger}"
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,214 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search as _Search, Filter as _Filter, Package, Sparkles, FileText, GitFork } from 'lucide-react';
|
||||
import { SkillCard } from '../components/SkillCard';
|
||||
import { Footer } from '../components/Footer';
|
||||
import type { SkillMetadata, SkillsIndex } from '../types';
|
||||
|
||||
export const SkillsCatalog: React.FC = () => {
|
||||
const [skills, setSkills] = useState<SkillMetadata[]>([]);
|
||||
const [filteredSkills, setFilteredSkills] = useState<SkillMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, _setSearchTerm] = useState('');
|
||||
const [categoryFilter, _setCategoryFilter] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSkills = async () => {
|
||||
try {
|
||||
const response = await fetch('./skills/index.json');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch skills index');
|
||||
}
|
||||
const data: SkillsIndex = await response.json();
|
||||
setSkills(data.skills || []);
|
||||
setFilteredSkills(data.skills || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load skills');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSkills();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let result = skills;
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(term) ||
|
||||
skill.description.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (categoryFilter !== 'all') {
|
||||
result = result.filter((skill) => skill.category === categoryFilter);
|
||||
}
|
||||
|
||||
setFilteredSkills(result);
|
||||
}, [searchTerm, categoryFilter, skills]);
|
||||
|
||||
// Get unique categories from skills (used in commented filter UI)
|
||||
const _categories = ['all', ...new Set(skills.map((s) => s.category).filter(Boolean))];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pt-[52px]">
|
||||
<div className="py-16 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-clawd-accent"></div>
|
||||
<p className="mt-4 text-gray-400">Loading skills...</p>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="pt-[52px]">
|
||||
<div className="py-16 text-center">
|
||||
<Package className="w-16 h-16 mx-auto text-gray-600 mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">No Skills Available</h2>
|
||||
<p className="text-gray-400 mb-4">{error}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Skills will appear here after the first skill release.
|
||||
</p>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-[52px] space-y-8">
|
||||
{/* Header */}
|
||||
<section className="text-center space-y-4">
|
||||
<h1 className="text-3xl md:text-4xl text-white">
|
||||
Skills Catalog
|
||||
</h1>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
Browse security skills for your AI agents. Each skill is verified for safety
|
||||
and distributed with checksums for integrity verification.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Filters - Hidden for now, uncomment when needed
|
||||
<section className="flex flex-col md:flex-row gap-4 max-w-4xl mx-auto">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search skills..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-clawd-800 border border-clawd-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-clawd-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={20} />
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="pl-10 pr-8 py-2.5 bg-clawd-800 border border-clawd-700 rounded-lg text-white appearance-none focus:outline-none focus:border-clawd-accent"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat === 'all' ? 'All Categories' : cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
*/}
|
||||
|
||||
{/* Skills Grid */}
|
||||
{filteredSkills.length > 0 ? (
|
||||
<section className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredSkills.map((skill) => (
|
||||
<SkillCard key={skill.id} skill={skill} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<section className="text-center py-12">
|
||||
<Package className="w-12 h-12 mx-auto text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-medium text-white mb-2">No skills found</h3>
|
||||
<p className="text-gray-400">
|
||||
{searchTerm || categoryFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'No skills have been released yet'}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{skills.length > 0 && (
|
||||
<section className="text-center text-sm text-gray-500">
|
||||
Showing {filteredSkills.length} of {skills.length} skills
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Shoutout */}
|
||||
<section className="max-w-4xl mx-auto">
|
||||
<div className="bg-clawd-900 border border-clawd-700 rounded-xl overflow-hidden">
|
||||
<div className="bg-clawd-800 px-6 py-4 border-b border-clawd-700 flex items-center justify-between">
|
||||
<h2 className="font-bold text-white flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-clawd-accent" />
|
||||
Contribute Security Skills
|
||||
</h2>
|
||||
<span className="text-xs font-mono text-gray-500">SKILLS-BASED</span>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<p className="text-gray-300 text-sm">
|
||||
Humans & agents: submit skills that make bots safer (prompt injection defenses, drift checks, tool hardening, policy enforcement).
|
||||
We’ll package them with checksums so everyone can verify integrity.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec/blob/main/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-4 p-4 rounded-lg bg-clawd-800/50 border border-clawd-700 hover:border-clawd-accent/50 transition-colors group"
|
||||
>
|
||||
<span className="text-2xl">📄</span>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-bold text-sm group-hover:text-clawd-accent transition-colors flex items-center gap-2">
|
||||
Read CONTRIBUTING.md
|
||||
<FileText size={14} className="text-gray-500" />
|
||||
</h4>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Guidelines for authoring, packaging, and releasing skills to the ClawSec catalog.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/prompt-security/clawsec/fork"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-4 p-4 rounded-lg bg-clawd-800/50 border border-clawd-700 hover:border-clawd-accent/50 transition-colors group"
|
||||
>
|
||||
<span className="text-2xl">🍴</span>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-bold text-sm group-hover:text-clawd-accent transition-colors flex items-center gap-2">
|
||||
Fork the repository
|
||||
<GitFork size={14} className="text-gray-500" />
|
||||
</h4>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Start a contribution branch and open a PR with your new security skill.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 788 KiB |
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1455.1 1298.3">
|
||||
<!-- Generator: Adobe Illustrator 29.0.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 186) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: none;
|
||||
stroke: #fff;
|
||||
stroke-width: 4px;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
opacity: .1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g class="st1">
|
||||
<path class="st0" d="M730,505.6l209.7,362.8M730,505.6l-209.6,362.8M730,505.6l-64.5-111.7c-26.1-45.2-91.4-45.2-117.5,0l-405.4,701.7c-23.9,41.4-23.9,92.4,0,133.8M939.7,868.4h-419.3M939.7,868.4h144.1c44.8,0,72.9-48.5,50.5-87.3l-281.7-487.6M520.4,868.4h563.4c44.8,0,72.9-48.5,50.5-87.3l-281.7-487.6M520.4,868.4l-73.2,126.6c-22.4,38.8,5.6,87.3,50.5,87.3h818.1M852.6,293.5l-102.3-177.1M852.6,293.5l-102.3-177.1h0M750.2,116.4l-27.3-47.2C698.8,27.6,654.4,1.9,606.3,1.9M606.4,2h245.3c48.5,0,93.2,25.8,117.5,67.8l465.9,806.4c24.2,41.9,24.2,93.6,0,135.5l-1.7,2.9M606.4,2c-48.1,0-92.6,25.6-116.6,67.3L20.2,882c-24.2,41.9-24.2,93.6,0,135.5l122.4,211.9M1315.8,1082.3c43.9,0,85-21.4,110.1-57.3l7.3-10.5M1315.8,1082.3c48.5,0,93.2-25.9,117.5-67.8M1433.3,1014.6h0l-39.2,67.8h0l-84.5,146.2c-24.2,41.9-69,67.8-117.4,67.8H258.5c-47.8,0-92-25.5-115.9-66.9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,33 +0,0 @@
|
||||
[project]
|
||||
name = "clawsec-utils"
|
||||
version = "0.1.0"
|
||||
description = "ClawSec skill utilities"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"F", # pyflakes
|
||||
"W", # pycodestyle warnings
|
||||
"I", # isort
|
||||
"S", # bandit (security)
|
||||
"B", # bugbear
|
||||
"C4", # comprehensions
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
ignore = [
|
||||
"S101", # Allow assert statements
|
||||
"S603", # Allow subprocess without shell=True check (we control inputs)
|
||||
"S607", # Allow partial executable paths
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"utils/__pycache__/*" = ["ALL"]
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["__pycache__", ".venv"]
|
||||
skips = ["B101"] # Allow assert
|
||||
@@ -1,332 +0,0 @@
|
||||
#!/bin/bash
|
||||
# populate-local-feed.sh
|
||||
# Polls NVD API for real CVE data and populates local advisory feed for development preview.
|
||||
# This mirrors the GitHub Actions pipeline logic exactly.
|
||||
#
|
||||
# Usage: ./scripts/populate-local-feed.sh [--days N] [--force]
|
||||
# --days N Look back N days (default: 120)
|
||||
# --force Ignore existing advisories and fetch all
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Configuration - same as pipeline
|
||||
FEED_PATH="$PROJECT_ROOT/advisories/feed.json"
|
||||
SKILL_FEED_PATH="$PROJECT_ROOT/skills/clawsec-feed/advisories/feed.json"
|
||||
PUBLIC_FEED_PATH="$PROJECT_ROOT/public/advisories/feed.json"
|
||||
KEYWORDS="OpenClaw clawdbot Moltbot"
|
||||
GITHUB_REF_PATTERN="github.com/openclaw/openclaw"
|
||||
|
||||
# Parse args
|
||||
DAYS_BACK=120
|
||||
FORCE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--days)
|
||||
DAYS_BACK="$2"
|
||||
shift 2
|
||||
;;
|
||||
--force)
|
||||
FORCE=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "=== ClawSec Local Feed Populator ==="
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
echo "Days back: $DAYS_BACK"
|
||||
echo "Force mode: $FORCE"
|
||||
echo ""
|
||||
|
||||
# Create temp directory
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
# Determine date window
|
||||
if [ -f "$FEED_PATH" ] && [ "$FORCE" = "false" ]; then
|
||||
LAST_UPDATED=$(jq -r '.updated // empty' "$FEED_PATH")
|
||||
if [ -n "$LAST_UPDATED" ]; then
|
||||
START_DATE="$LAST_UPDATED"
|
||||
echo "Using last updated from feed: $START_DATE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${START_DATE:-}" ]; then
|
||||
# macOS vs Linux date compatibility
|
||||
if date -v-1d > /dev/null 2>&1; then
|
||||
START_DATE=$(date -u -v-${DAYS_BACK}d +%Y-%m-%dT%H:%M:%S.000Z)
|
||||
else
|
||||
START_DATE=$(date -u -d "${DAYS_BACK} days ago" +%Y-%m-%dT%H:%M:%S.000Z)
|
||||
fi
|
||||
echo "Using default start date: $START_DATE"
|
||||
fi
|
||||
|
||||
END_DATE=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
|
||||
echo "End date: $END_DATE"
|
||||
echo ""
|
||||
|
||||
# URL encode dates
|
||||
START_ENC=$(echo "$START_DATE" | sed 's/:/%3A/g')
|
||||
END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
|
||||
|
||||
echo "=== Fetching CVEs from NVD ==="
|
||||
|
||||
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}"
|
||||
|
||||
# Fetch with retry logic
|
||||
for i in 1 2 3; do
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$TEMP_DIR/nvd_${KEYWORD}.json" "$URL")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
COUNT=$(jq '.vulnerabilities | length // 0' "$TEMP_DIR/nvd_${KEYWORD}.json" 2>/dev/null || echo 0)
|
||||
echo " ✓ Found $COUNT CVEs"
|
||||
break
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
|
||||
echo " Rate limited, waiting 30s before retry $i..."
|
||||
sleep 30
|
||||
else
|
||||
echo " HTTP $HTTP_CODE, retry $i..."
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
|
||||
# NVD recommends 6 second delay between requests
|
||||
echo " Waiting 6s (NVD rate limit)..."
|
||||
sleep 6
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Processing CVEs ==="
|
||||
|
||||
# Combine all fetched CVEs
|
||||
echo '{"vulnerabilities":[]}' > "$TEMP_DIR/combined.json"
|
||||
|
||||
for KEYWORD in $KEYWORDS; do
|
||||
FILE="$TEMP_DIR/nvd_${KEYWORD}.json"
|
||||
if [ -f "$FILE" ] && [ -s "$FILE" ]; then
|
||||
if jq -e '.vulnerabilities' "$FILE" > /dev/null 2>&1; then
|
||||
jq -s '.[0].vulnerabilities += .[1].vulnerabilities | .[0]' \
|
||||
"$TEMP_DIR/combined.json" "$FILE" > "$TEMP_DIR/combined_new.json"
|
||||
mv "$TEMP_DIR/combined_new.json" "$TEMP_DIR/combined.json"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Deduplicate by CVE ID
|
||||
jq '.vulnerabilities | unique_by(.cve.id)' "$TEMP_DIR/combined.json" > "$TEMP_DIR/unique_cves.json"
|
||||
TOTAL=$(jq 'length' "$TEMP_DIR/unique_cves.json")
|
||||
echo "Total unique CVEs from NVD: $TOTAL"
|
||||
|
||||
# Post-filter: keep only CVEs matching our criteria
|
||||
KEYWORDS_PATTERN="OpenClaw|clawdbot|Moltbot|openclaw"
|
||||
|
||||
jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_REF_PATTERN" '
|
||||
[.[] | select(
|
||||
(.cve.descriptions[]? | select(.lang == "en") | .value | test($kw; "i"))
|
||||
or
|
||||
(.cve.references[]? | .url | test($gh; "i"))
|
||||
)]
|
||||
' "$TEMP_DIR/unique_cves.json" > "$TEMP_DIR/filtered_cves.json"
|
||||
|
||||
FILTERED=$(jq 'length' "$TEMP_DIR/filtered_cves.json")
|
||||
echo "Filtered CVEs (matching criteria): $FILTERED"
|
||||
|
||||
# Get existing advisory IDs (unless force mode)
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
echo "Force mode: ignoring existing advisory IDs during transform"
|
||||
EXISTING_IDS=""
|
||||
elif [ -f "$FEED_PATH" ]; then
|
||||
EXISTING_IDS=$(jq -r '.advisories[]?.id // empty' "$FEED_PATH" | sort -u)
|
||||
else
|
||||
EXISTING_IDS=""
|
||||
fi
|
||||
|
||||
# Transform CVEs to our advisory format (same logic as pipeline)
|
||||
EXISTING_JSON=$(echo "$EXISTING_IDS" | jq -R -s 'split("\n") | map(select(length > 0))')
|
||||
|
||||
jq --argjson existing "$EXISTING_JSON" '
|
||||
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;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
else (cwe_name_map($id) // ("unknown_cwe_" + $id))
|
||||
end
|
||||
);
|
||||
|
||||
[.[] |
|
||||
select(.cve.id as $id | $existing | index($id) | not) |
|
||||
{
|
||||
id: .cve.id,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: nvd_category_name,
|
||||
nvd_category_id: nvd_category_raw,
|
||||
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)
|
||||
}
|
||||
]
|
||||
' "$TEMP_DIR/filtered_cves.json" > "$TEMP_DIR/new_advisories.json"
|
||||
|
||||
NEW_COUNT=$(jq 'length' "$TEMP_DIR/new_advisories.json")
|
||||
echo "New advisories to add: $NEW_COUNT"
|
||||
|
||||
if [ "$NEW_COUNT" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "No new CVEs found. Feed is up to date."
|
||||
echo "Use --force to re-fetch all CVEs regardless of existing entries."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== New Advisories ==="
|
||||
jq -r '.[] | " \(.id) [\(.severity)] - \(.title)"' "$TEMP_DIR/new_advisories.json"
|
||||
|
||||
echo ""
|
||||
echo "=== Updating Feeds ==="
|
||||
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Merge new advisories into existing feed
|
||||
if [ -f "$FEED_PATH" ]; then
|
||||
jq --argjson new "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '
|
||||
.updated = $now |
|
||||
# Merge by advisory ID so force mode can refresh existing CVEs without duplicates
|
||||
.advisories = (
|
||||
reduce (.advisories + $new)[] as $adv
|
||||
({};
|
||||
if ($adv.id // "") == "" then
|
||||
.
|
||||
else
|
||||
.[$adv.id] = $adv
|
||||
end
|
||||
)
|
||||
| [.[]]
|
||||
| sort_by(.published)
|
||||
| reverse
|
||||
)
|
||||
' "$FEED_PATH" > "$TEMP_DIR/updated_feed.json"
|
||||
else
|
||||
jq -n --argjson advisories "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '{
|
||||
version: "1.0.0",
|
||||
updated: $now,
|
||||
description: "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD.",
|
||||
advisories: ($advisories | sort_by(.published) | reverse)
|
||||
}' > "$TEMP_DIR/updated_feed.json"
|
||||
fi
|
||||
|
||||
# Validate and save
|
||||
if jq empty "$TEMP_DIR/updated_feed.json" 2>/dev/null; then
|
||||
# Update main feed
|
||||
cp "$TEMP_DIR/updated_feed.json" "$FEED_PATH"
|
||||
echo "✓ Updated: $FEED_PATH"
|
||||
|
||||
# Update skill feed
|
||||
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
|
||||
cp "$FEED_PATH" "$SKILL_FEED_PATH"
|
||||
echo "✓ Updated: $SKILL_FEED_PATH"
|
||||
|
||||
# Update public feed for local dev
|
||||
mkdir -p "$(dirname "$PUBLIC_FEED_PATH")"
|
||||
cp "$FEED_PATH" "$PUBLIC_FEED_PATH"
|
||||
echo "✓ Updated: $PUBLIC_FEED_PATH"
|
||||
|
||||
echo ""
|
||||
TOTAL_ADVISORIES=$(jq '.advisories | length' "$FEED_PATH")
|
||||
echo "=== Summary ==="
|
||||
echo "Total advisories in feed: $TOTAL_ADVISORIES"
|
||||
echo "New advisories added: $NEW_COUNT"
|
||||
echo ""
|
||||
echo "Run 'npm run dev' to preview the feed in the local site."
|
||||
else
|
||||
echo "Error: Generated invalid JSON"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/bin/bash
|
||||
# populate-local-skills.sh
|
||||
# Builds local skills index from skills/ directory for development preview.
|
||||
# This mirrors the skill-release.yml pipeline exactly - generates real checksums.
|
||||
#
|
||||
# Usage: ./scripts/populate-local-skills.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
PUBLIC_SKILLS_DIR="$PROJECT_ROOT/public/skills"
|
||||
DIST_DIR="$PROJECT_ROOT/dist/skills"
|
||||
|
||||
echo "=== ClawSec Local Skills Populator ==="
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
echo ""
|
||||
|
||||
# Create directories
|
||||
mkdir -p "$PUBLIC_SKILLS_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# Start building skills index
|
||||
echo '{"version":"1.0.0","updated":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","skills":[' > "$PUBLIC_SKILLS_DIR/index.json"
|
||||
|
||||
FIRST_SKILL=true
|
||||
SKILL_COUNT=0
|
||||
|
||||
echo "=== Discovering Skills ==="
|
||||
|
||||
# Process each skill directory
|
||||
for SKILL_DIR in "$PROJECT_ROOT/skills"/*/; do
|
||||
SKILL_NAME=$(basename "$SKILL_DIR")
|
||||
SKILL_JSON="$SKILL_DIR/skill.json"
|
||||
|
||||
# Skip if no skill.json
|
||||
if [ ! -f "$SKILL_JSON" ]; then
|
||||
echo "⚠️ Skipping $SKILL_NAME (no skill.json)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Processing: $SKILL_NAME"
|
||||
|
||||
# Check if internal skill
|
||||
IS_INTERNAL=$(jq -r '.openclaw.internal // false' "$SKILL_JSON")
|
||||
if [ "$IS_INTERNAL" = "true" ]; then
|
||||
echo " ⚠️ Skipping internal skill: $SKILL_NAME"
|
||||
continue
|
||||
fi
|
||||
|
||||
VERSION=$(jq -r '.version' "$SKILL_JSON")
|
||||
TAG="${SKILL_NAME}-v${VERSION}"
|
||||
|
||||
# Create skill directory in public
|
||||
mkdir -p "$PUBLIC_SKILLS_DIR/$SKILL_NAME"
|
||||
|
||||
# Copy skill.json
|
||||
cp "$SKILL_JSON" "$PUBLIC_SKILLS_DIR/$SKILL_NAME/skill.json"
|
||||
echo " ✓ Copied: skill.json"
|
||||
|
||||
# Copy README.md if exists
|
||||
if [ -f "$SKILL_DIR/README.md" ]; then
|
||||
cp "$SKILL_DIR/README.md" "$PUBLIC_SKILLS_DIR/$SKILL_NAME/README.md"
|
||||
echo " ✓ Copied: README.md"
|
||||
fi
|
||||
|
||||
# Copy SBOM markdown docs (SKILL.md, HEARTBEAT.md, etc.) for website display
|
||||
TEMPFILE=$(mktemp)
|
||||
jq -r '.sbom.files[].path' "$SKILL_JSON" > "$TEMPFILE" 2>/dev/null || true
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
|
||||
case "$file" in
|
||||
*.md|*.MD)
|
||||
FULL_PATH="$SKILL_DIR/$file"
|
||||
if [ -f "$FULL_PATH" ]; then
|
||||
cp "$FULL_PATH" "$PUBLIC_SKILLS_DIR/$SKILL_NAME/$(basename "$file")"
|
||||
echo " ✓ Copied: $(basename "$file")"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done < "$TEMPFILE"
|
||||
rm -f "$TEMPFILE"
|
||||
|
||||
# === Generate real checksums from SBOM (mirrors skill-release.yml) ===
|
||||
CHECKSUMS_FILE="$PUBLIC_SKILLS_DIR/$SKILL_NAME/checksums.json"
|
||||
|
||||
cat > "$CHECKSUMS_FILE" << EOF
|
||||
{
|
||||
"skill": "$SKILL_NAME",
|
||||
"version": "$VERSION",
|
||||
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"repository": "prompt-security/ClawSec",
|
||||
"tag": "$TAG",
|
||||
"files": {
|
||||
EOF
|
||||
|
||||
FIRST_FILE=true
|
||||
TEMPFILE=$(mktemp)
|
||||
|
||||
# Get files from SBOM
|
||||
jq -r '.sbom.files[].path' "$SKILL_JSON" > "$TEMPFILE" 2>/dev/null || echo ""
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
FULL_PATH="$SKILL_DIR/$file"
|
||||
if [ -f "$FULL_PATH" ]; then
|
||||
# macOS uses shasum, Linux uses sha256sum
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
SHA256=$(sha256sum "$FULL_PATH" | awk '{print $1}')
|
||||
else
|
||||
SHA256=$(shasum -a 256 "$FULL_PATH" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
# macOS vs Linux stat
|
||||
SIZE=$(stat -f%z "$FULL_PATH" 2>/dev/null || stat -c%s "$FULL_PATH")
|
||||
FILENAME=$(basename "$file")
|
||||
|
||||
if [ "$FIRST_FILE" = true ]; then
|
||||
FIRST_FILE=false
|
||||
else
|
||||
echo "," >> "$CHECKSUMS_FILE"
|
||||
fi
|
||||
|
||||
cat >> "$CHECKSUMS_FILE" << FILEENTRY
|
||||
"$FILENAME": {
|
||||
"sha256": "$SHA256",
|
||||
"size": $SIZE,
|
||||
"path": "$file",
|
||||
"url": "https://clawsec.prompt.security/releases/download/$TAG/$FILENAME"
|
||||
}
|
||||
FILEENTRY
|
||||
echo " ✓ Checksum: $FILENAME ($SHA256)"
|
||||
else
|
||||
echo " ⚠️ File not found: $file"
|
||||
fi
|
||||
done < "$TEMPFILE"
|
||||
rm -f "$TEMPFILE"
|
||||
|
||||
# Add skill.json checksum
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
SKILL_JSON_SHA=$(sha256sum "$SKILL_JSON" | awk '{print $1}')
|
||||
else
|
||||
SKILL_JSON_SHA=$(shasum -a 256 "$SKILL_JSON" | awk '{print $1}')
|
||||
fi
|
||||
SKILL_JSON_SIZE=$(stat -f%z "$SKILL_JSON" 2>/dev/null || stat -c%s "$SKILL_JSON")
|
||||
|
||||
if [ "$FIRST_FILE" = false ]; then
|
||||
echo "," >> "$CHECKSUMS_FILE"
|
||||
fi
|
||||
|
||||
cat >> "$CHECKSUMS_FILE" << SKILLJSON
|
||||
"skill.json": {
|
||||
"sha256": "$SKILL_JSON_SHA",
|
||||
"size": $SKILL_JSON_SIZE,
|
||||
"url": "https://clawsec.prompt.security/releases/download/$TAG/skill.json"
|
||||
}
|
||||
SKILLJSON
|
||||
|
||||
# Close checksums JSON
|
||||
cat >> "$CHECKSUMS_FILE" << EOF
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo " ✓ Generated: checksums.json"
|
||||
|
||||
# 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,
|
||||
tag: $tag
|
||||
}' "$SKILL_JSON")
|
||||
|
||||
# Append to index
|
||||
if [ "$FIRST_SKILL" = "true" ]; then
|
||||
FIRST_SKILL=false
|
||||
else
|
||||
echo "," >> "$PUBLIC_SKILLS_DIR/index.json"
|
||||
fi
|
||||
echo "$SKILL_DATA" >> "$PUBLIC_SKILLS_DIR/index.json"
|
||||
|
||||
SKILL_COUNT=$((SKILL_COUNT + 1))
|
||||
echo " ✓ Added to index"
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Close the JSON array
|
||||
echo ']}' >> "$PUBLIC_SKILLS_DIR/index.json"
|
||||
|
||||
echo "=== Skills Index ==="
|
||||
jq '.' "$PUBLIC_SKILLS_DIR/index.json"
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo "Total skills indexed: $SKILL_COUNT"
|
||||
echo "Skills directory: $PUBLIC_SKILLS_DIR"
|
||||
echo ""
|
||||
ls -la "$PUBLIC_SKILLS_DIR"/*/
|
||||
echo ""
|
||||
echo "Run 'npm run dev' to preview the skills catalog."
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# prepare-to-push.sh - Run all checks before pushing to ensure CI will pass
|
||||
#
|
||||
# Usage: ./scripts/prepare-to-push.sh [--fix]
|
||||
#
|
||||
# Options:
|
||||
# --fix Attempt to auto-fix issues where possible
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure Homebrew tools are in PATH (macOS)
|
||||
if [[ -d "/opt/homebrew/bin" ]]; then
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
fi
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Track failures
|
||||
FAILURES=0
|
||||
|
||||
# Parse arguments
|
||||
FIX_MODE=false
|
||||
if [[ "${1:-}" == "--fix" ]]; then
|
||||
FIX_MODE=true
|
||||
echo -e "${BLUE}🔧 Running in fix mode - will attempt to auto-fix issues${NC}\n"
|
||||
fi
|
||||
|
||||
# Helper functions
|
||||
print_header() {
|
||||
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}▶ $1${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
}
|
||||
|
||||
check_pass() {
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
}
|
||||
|
||||
check_fail() {
|
||||
echo -e "${RED}✗ $1${NC}"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
}
|
||||
|
||||
check_warn() {
|
||||
echo -e "${YELLOW}⚠ $1${NC}"
|
||||
}
|
||||
|
||||
check_skip() {
|
||||
echo -e "${YELLOW}⊘ $1 (skipped - tool not installed)${NC}"
|
||||
}
|
||||
|
||||
# Change to repo root
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO_ROOT=$(pwd)
|
||||
echo -e "${BLUE}📁 Repository: ${REPO_ROOT}${NC}"
|
||||
|
||||
# ============================================================================
|
||||
# TypeScript / React Checks
|
||||
# ============================================================================
|
||||
|
||||
print_header "TypeScript / React"
|
||||
|
||||
# Check if node_modules exists
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "Installing npm dependencies..."
|
||||
npm ci
|
||||
fi
|
||||
|
||||
# ESLint
|
||||
echo -e "\n${YELLOW}Running ESLint...${NC}"
|
||||
if $FIX_MODE; then
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --fix; then
|
||||
check_pass "ESLint (with auto-fix)"
|
||||
else
|
||||
check_fail "ESLint found unfixable issues"
|
||||
fi
|
||||
else
|
||||
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0; then
|
||||
check_pass "ESLint"
|
||||
else
|
||||
check_fail "ESLint found issues (run with --fix to auto-fix)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# TypeScript
|
||||
echo -e "\n${YELLOW}Running TypeScript type check...${NC}"
|
||||
if npx tsc --noEmit; then
|
||||
check_pass "TypeScript type check"
|
||||
else
|
||||
check_fail "TypeScript type errors found"
|
||||
fi
|
||||
|
||||
# Build
|
||||
echo -e "\n${YELLOW}Running build...${NC}"
|
||||
if npm run build; then
|
||||
check_pass "Vite build"
|
||||
else
|
||||
check_fail "Build failed"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Python Checks
|
||||
# ============================================================================
|
||||
|
||||
print_header "Python"
|
||||
|
||||
# Check for Python files
|
||||
if [ -d "utils" ] && ls utils/*.py 1> /dev/null 2>&1; then
|
||||
|
||||
# Ruff
|
||||
if command -v ruff &> /dev/null; then
|
||||
echo -e "\n${YELLOW}Running Ruff...${NC}"
|
||||
if $FIX_MODE; then
|
||||
if ruff check utils/ --fix; then
|
||||
check_pass "Ruff (with auto-fix)"
|
||||
else
|
||||
check_fail "Ruff found unfixable issues"
|
||||
fi
|
||||
else
|
||||
if ruff check utils/; then
|
||||
check_pass "Ruff"
|
||||
else
|
||||
check_fail "Ruff found issues (run with --fix to auto-fix)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
check_skip "Ruff"
|
||||
echo " Install with: pip install ruff"
|
||||
fi
|
||||
|
||||
# Bandit
|
||||
if command -v bandit &> /dev/null; then
|
||||
echo -e "\n${YELLOW}Running Bandit security scan...${NC}"
|
||||
if bandit -r utils/ -ll; then
|
||||
check_pass "Bandit security scan"
|
||||
else
|
||||
check_fail "Bandit found security issues"
|
||||
fi
|
||||
else
|
||||
check_skip "Bandit"
|
||||
echo " Install with: pip install bandit"
|
||||
fi
|
||||
|
||||
else
|
||||
check_warn "No Python files found in utils/"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Shell Script Checks
|
||||
# ============================================================================
|
||||
|
||||
print_header "Shell Scripts"
|
||||
|
||||
if command -v shellcheck &> /dev/null; then
|
||||
echo -e "\n${YELLOW}Running ShellCheck...${NC}"
|
||||
SHELL_ERRORS=0
|
||||
for script in scripts/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if shellcheck -S warning "$script"; then
|
||||
echo -e " ${GREEN}✓${NC} $script"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $script"
|
||||
SHELL_ERRORS=$((SHELL_ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ $SHELL_ERRORS -eq 0 ]; then
|
||||
check_pass "ShellCheck"
|
||||
else
|
||||
check_fail "ShellCheck found issues in $SHELL_ERRORS file(s)"
|
||||
fi
|
||||
else
|
||||
check_skip "ShellCheck"
|
||||
echo " Install with: brew install shellcheck"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Security Scans
|
||||
# ============================================================================
|
||||
|
||||
print_header "Security"
|
||||
|
||||
# Trivy FS Scan
|
||||
if command -v trivy &> /dev/null; then
|
||||
echo -e "\n${YELLOW}Running Trivy filesystem scan...${NC}"
|
||||
if trivy fs . --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed; then
|
||||
check_pass "Trivy filesystem scan"
|
||||
else
|
||||
check_fail "Trivy found CRITICAL/HIGH vulnerabilities"
|
||||
fi
|
||||
|
||||
echo -e "\n${YELLOW}Running Trivy config scan...${NC}"
|
||||
# Suppress info/warnings about missing config files (expected for non-IaC projects)
|
||||
if trivy config . --severity CRITICAL,HIGH --exit-code 1 --quiet 2>&1 | grep -v "Supported files for scanner(s) not found"; then
|
||||
check_pass "Trivy config scan"
|
||||
else
|
||||
check_fail "Trivy found CRITICAL/HIGH config issues"
|
||||
fi
|
||||
else
|
||||
check_skip "Trivy"
|
||||
echo " Install with: brew install trivy"
|
||||
fi
|
||||
|
||||
# Gitleaks (scans git history to match CI)
|
||||
if command -v gitleaks &> /dev/null; then
|
||||
echo -e "\n${YELLOW}Running Gitleaks secrets scan...${NC}"
|
||||
if gitleaks detect --source . --verbose; then
|
||||
check_pass "Gitleaks secrets scan"
|
||||
else
|
||||
check_fail "Gitleaks found potential secrets"
|
||||
fi
|
||||
else
|
||||
check_skip "Gitleaks"
|
||||
echo " Install with: brew install gitleaks"
|
||||
fi
|
||||
|
||||
# npm audit (use public registry since private registries like CodeArtifact don't support audit)
|
||||
echo -e "\n${YELLOW}Running npm audit...${NC}"
|
||||
if npm audit --audit-level=high --registry=https://registry.npmjs.org; then
|
||||
check_pass "npm audit"
|
||||
else
|
||||
check_warn "npm audit found vulnerabilities (run 'npm audit fix')"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Summary
|
||||
# ============================================================================
|
||||
|
||||
print_header "Summary"
|
||||
|
||||
if [ $FAILURES -eq 0 ]; then
|
||||
echo -e "\n${GREEN}🎉 All checks passed! Ready to push.${NC}\n"
|
||||
exit 0
|
||||
else
|
||||
echo -e "\n${RED}❌ $FAILURES check(s) failed. Please fix before pushing.${NC}"
|
||||
echo -e "${YELLOW}💡 Tip: Run with --fix to auto-fix some issues${NC}\n"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,342 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Usage: ./scripts/release-skill.sh <skill-name> <version> [--force-tag]
|
||||
# Example: ./scripts/release-skill.sh clawsec-feed 1.1.0
|
||||
#
|
||||
# This script ensures version consistency by:
|
||||
# 1. Updating skill.json with the new version
|
||||
# 2. Updating any hardcoded version URLs in skill.json and SKILL.md
|
||||
# 3. Committing the changes
|
||||
# 4. Creating the git tag (only on main/master branch)
|
||||
#
|
||||
# Branch-aware workflow:
|
||||
# Feature branch: Updates versions, commits, pushes → CI validates build
|
||||
# Main branch: Updates versions, commits, creates tag → push triggers release
|
||||
#
|
||||
# Use --force-tag to create a tag even when not on main/master.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Parse arguments
|
||||
FORCE_TAG=false
|
||||
POSITIONAL_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--force-tag)
|
||||
FORCE_TAG=true
|
||||
;;
|
||||
*)
|
||||
POSITIONAL_ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${#POSITIONAL_ARGS[@]}" -ne 2 ]; then
|
||||
echo "Usage: $0 <skill-name> <version> [--force-tag]"
|
||||
echo "Example: $0 clawsec-feed 1.1.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILL_NAME="${POSITIONAL_ARGS[0]}"
|
||||
VERSION="${POSITIONAL_ARGS[1]}"
|
||||
SKILL_PATH="skills/$SKILL_NAME"
|
||||
|
||||
# Initialize variables
|
||||
RELEASE_NOTES=""
|
||||
|
||||
# Ensure we're on a branch (not detached HEAD) so release flow works from feature branches
|
||||
CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)"
|
||||
if [ -z "$CURRENT_BRANCH" ]; then
|
||||
echo "Error: Detached HEAD detected. Checkout a branch before running release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine if we're on a release branch (main/master)
|
||||
IS_RELEASE_BRANCH=false
|
||||
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
|
||||
IS_RELEASE_BRANCH=true
|
||||
fi
|
||||
|
||||
# Security: Validate skill name to prevent path injection
|
||||
# Only allow lowercase alphanumeric characters and hyphens
|
||||
if ! [[ "$SKILL_NAME" =~ ^[a-z0-9-]+$ ]]; then
|
||||
echo "Error: Invalid skill name. Only lowercase alphanumeric characters and hyphens are allowed."
|
||||
echo "Example: clawsec-feed, prompt-agent, clawtributor"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SKILL_PATH/skill.json" ]; then
|
||||
echo "Error: $SKILL_PATH/skill.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate semver format (supports prerelease like 1.0.0-beta1)
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then
|
||||
echo "Error: Invalid version format. Use semver (e.g., 1.0.0, 1.1.0-beta1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="${SKILL_NAME}-v${VERSION}"
|
||||
|
||||
# Check for uncommitted changes in skill directory
|
||||
if ! git diff --quiet "$SKILL_PATH/" 2>/dev/null; then
|
||||
echo "Error: $SKILL_PATH/ has uncommitted changes. Please commit or stash them first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Releasing $SKILL_NAME version $VERSION"
|
||||
echo "Branch: $CURRENT_BRANCH"
|
||||
if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then
|
||||
echo "Mode: Full release (will create tag)"
|
||||
else
|
||||
echo "Mode: Prep only (tag deferred until merge to main)"
|
||||
fi
|
||||
echo "======================================="
|
||||
|
||||
# Create a temporary directory for atomic operations
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
# Track files that need to be staged
|
||||
FILES_TO_STAGE=()
|
||||
|
||||
# Update version in skill.json
|
||||
echo "Updating $SKILL_PATH/skill.json version to $VERSION..."
|
||||
if ! jq --arg v "$VERSION" '.version = $v' "$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then
|
||||
echo "Error: Failed to update version in skill.json"
|
||||
exit 1
|
||||
fi
|
||||
mv "$TEMP_DIR/skill.json" "$SKILL_PATH/skill.json"
|
||||
FILES_TO_STAGE+=("$SKILL_PATH/skill.json")
|
||||
|
||||
# Update any hardcoded version URLs in skill.json (openclaw.feed_url pattern)
|
||||
if jq -e '.openclaw.feed_url' "$SKILL_PATH/skill.json" >/dev/null 2>&1; then
|
||||
echo "Updating openclaw.feed_url to use tag $TAG..."
|
||||
if ! jq --arg tag "$TAG" '.openclaw.feed_url = (.openclaw.feed_url | gsub("/[^/]+-v[0-9.]+(-[a-zA-Z0-9]+)?/"; "/\($tag)/"))' "$SKILL_PATH/skill.json" > "$TEMP_DIR/skill.json"; then
|
||||
echo "Error: Failed to update feed_url in skill.json"
|
||||
exit 1
|
||||
fi
|
||||
mv "$TEMP_DIR/skill.json" "$SKILL_PATH/skill.json"
|
||||
fi
|
||||
|
||||
# Update version in SKILL.md frontmatter and ALL hardcoded version URLs (if file exists)
|
||||
if [ -f "$SKILL_PATH/SKILL.md" ]; then
|
||||
echo "Updating $SKILL_PATH/SKILL.md frontmatter version to $VERSION..."
|
||||
|
||||
# Verify version line exists before sed
|
||||
if ! grep -qE "^version: " "$SKILL_PATH/SKILL.md"; then
|
||||
echo "Error: SKILL.md missing 'version:' line in frontmatter" >&2
|
||||
echo " Expected format: 'version: X.Y.Z' at start of line" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Apply sed and verify substitution occurred
|
||||
sed "s/^version: .*/version: $VERSION/" "$SKILL_PATH/SKILL.md" > "$TEMP_DIR/SKILL.md"
|
||||
|
||||
if ! grep -qF "version: $VERSION" "$TEMP_DIR/SKILL.md"; then
|
||||
echo "Error: Failed to update version in SKILL.md frontmatter" >&2
|
||||
echo " Target version: $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ✓ Version updated to $VERSION"
|
||||
|
||||
echo "Updating hardcoded version URLs in SKILL.md to use tag $TAG..."
|
||||
# Replace all hardcoded version URLs: download/SKILLNAME-vX.Y.Z(-prerelease)?/ -> download/TAG/
|
||||
# This handles patterns like: download/clawsec-feed-v1.0.0/ or download/prompt-agent-v1.0.0-beta1/
|
||||
PATTERN="/download/${SKILL_NAME}-v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?/"
|
||||
|
||||
# Check if pattern exists (warn if not, don't fail - some skills may not self-reference)
|
||||
if grep -qE "$PATTERN" "$TEMP_DIR/SKILL.md"; then
|
||||
sed -E "s|$PATTERN|/download/${TAG}/|g" "$TEMP_DIR/SKILL.md" > "$TEMP_DIR/SKILL.md.tmp"
|
||||
|
||||
# Verify substitution occurred
|
||||
if ! grep -qF "/download/${TAG}/" "$TEMP_DIR/SKILL.md.tmp"; then
|
||||
echo "Warning: URL pattern found but substitution may have failed" >&2
|
||||
else
|
||||
URL_COUNT=$(grep -cF "/download/${TAG}/" "$TEMP_DIR/SKILL.md.tmp")
|
||||
echo " ✓ Updated $URL_COUNT hardcoded URL(s)"
|
||||
fi
|
||||
|
||||
mv "$TEMP_DIR/SKILL.md.tmp" "$TEMP_DIR/SKILL.md"
|
||||
else
|
||||
echo " ℹ No hardcoded version URLs found (OK if skill doesn't self-reference)"
|
||||
fi
|
||||
|
||||
mv "$TEMP_DIR/SKILL.md" "$SKILL_PATH/SKILL.md"
|
||||
FILES_TO_STAGE+=("$SKILL_PATH/SKILL.md")
|
||||
fi
|
||||
|
||||
# Update hardcoded version URLs in other markdown files (heartbeat.md, reporting.md, etc.)
|
||||
for md_file in "$SKILL_PATH"/*.md; do
|
||||
if [ -f "$md_file" ] && [ "$md_file" != "$SKILL_PATH/SKILL.md" ]; then
|
||||
filename=$(basename "$md_file")
|
||||
echo "Updating hardcoded version URLs in $filename..."
|
||||
|
||||
PATTERN="/download/${SKILL_NAME}-v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?/"
|
||||
|
||||
# Check if pattern exists
|
||||
if grep -qE "$PATTERN" "$md_file"; then
|
||||
sed -E "s|$PATTERN|/download/${TAG}/|g" "$md_file" > "$TEMP_DIR/$filename"
|
||||
|
||||
# Verify substitution occurred
|
||||
if ! grep -qF "/download/${TAG}/" "$TEMP_DIR/$filename"; then
|
||||
echo " Warning: URL pattern found but substitution may have failed in $filename" >&2
|
||||
else
|
||||
URL_COUNT=$(grep -cF "/download/${TAG}/" "$TEMP_DIR/$filename")
|
||||
echo " ✓ Updated $URL_COUNT URL(s) in $filename"
|
||||
fi
|
||||
|
||||
mv "$TEMP_DIR/$filename" "$md_file"
|
||||
FILES_TO_STAGE+=("$md_file")
|
||||
else
|
||||
echo " ℹ No hardcoded version URLs found in $filename (skipping)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Show what changed
|
||||
echo ""
|
||||
echo "Changes to $SKILL_PATH/:"
|
||||
git diff "$SKILL_PATH/" || true
|
||||
echo ""
|
||||
|
||||
# Stage all changed files atomically
|
||||
echo "Staging changes..."
|
||||
for file in "${FILES_TO_STAGE[@]}"; do
|
||||
git add "$file"
|
||||
done
|
||||
|
||||
# Verify staged changes before committing
|
||||
MADE_COMMIT=false
|
||||
if git diff --cached --quiet; then
|
||||
echo "Note: Version already at $VERSION — no changes to commit"
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
else
|
||||
# Commit the version bump
|
||||
echo "Committing changes..."
|
||||
if ! git commit -m "chore($SKILL_NAME): bump version to $VERSION"; then
|
||||
echo "Error: Failed to commit changes"
|
||||
exit 1
|
||||
fi
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
MADE_COMMIT=true
|
||||
fi
|
||||
|
||||
# Save commit SHA for recovery
|
||||
echo "Committed: $COMMIT_SHA"
|
||||
|
||||
# Create tag only on release branches (or if forced)
|
||||
if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then
|
||||
# Check if tag already exists (only matters when we're creating one)
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: Tag $TAG already exists"
|
||||
if [[ "$MADE_COMMIT" == "true" ]]; then
|
||||
echo "Rolling back version-bump commit..."
|
||||
git reset --hard HEAD~1
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating tag: $TAG"
|
||||
if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then
|
||||
echo "Error: Failed to create tag $TAG" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has been created but NOT tagged:" >&2
|
||||
echo " Commit: $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo "Recovery options:" >&2
|
||||
echo " 1. Fix the issue and tag manually:" >&2
|
||||
echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo " 2. Investigate why tagging failed:" >&2
|
||||
echo " - Check if tag exists: git tag -l '$TAG'" >&2
|
||||
echo " - Check permissions: ls -ld .git/refs/tags" >&2
|
||||
echo "" >&2
|
||||
echo " 3. To rollback the commit (if desired):" >&2
|
||||
echo " git reset --hard HEAD~1" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has NOT been pushed. Fix the issue before pushing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract changelog entry for this version and create GitHub release
|
||||
RELEASE_NOTES=""
|
||||
GH_RELEASE_CREATED=false
|
||||
if [ -f "$SKILL_PATH/CHANGELOG.md" ]; then
|
||||
echo "Extracting changelog entry for version $VERSION..."
|
||||
|
||||
# Extract the changelog section for this version
|
||||
# Pattern: ## [VERSION] - DATE ... until next ## [ or end of file
|
||||
RELEASE_NOTES=$(awk -v version="$VERSION" '
|
||||
BEGIN { in_section = 0; found = 0 }
|
||||
$0 ~ ("^## \\[" version "\\]") { in_section = 1; found = 1; next }
|
||||
in_section && /^## \[/ && found { exit }
|
||||
in_section { print }
|
||||
' "$SKILL_PATH/CHANGELOG.md" | sed '/^$/d' | sed '1{/^$/d;}')
|
||||
|
||||
if [ -n "$RELEASE_NOTES" ]; then
|
||||
echo "Found changelog entry with $(echo "$RELEASE_NOTES" | wc -l) lines"
|
||||
|
||||
# Create GitHub release with changelog notes
|
||||
echo "Creating GitHub release with changelog notes..."
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
if ! echo "$RELEASE_NOTES" | gh release create "$TAG" \
|
||||
--title "$SKILL_NAME v$VERSION" \
|
||||
--notes-file -; then
|
||||
echo "Warning: Failed to create GitHub release, but tag was created successfully" >&2
|
||||
echo "You can manually create the release at: https://github.com/$(git remote get-url origin | sed 's/.*github.com[:/]\([^.]*\).*/\1/')/releases/new" >&2
|
||||
else
|
||||
echo "✓ GitHub release created with changelog notes"
|
||||
GH_RELEASE_CREATED=true
|
||||
fi
|
||||
else
|
||||
echo "Warning: GitHub CLI (gh) not found. Skipping automatic release creation." >&2
|
||||
echo "Install GitHub CLI and run manually:" >&2
|
||||
echo " gh release create '$TAG' --title '$SKILL_NAME v$VERSION' --notes-file <(echo \"$RELEASE_NOTES\")" >&2
|
||||
fi
|
||||
else
|
||||
echo "Warning: No changelog entry found for version $VERSION" >&2
|
||||
fi
|
||||
else
|
||||
echo "No CHANGELOG.md found in $SKILL_PATH - skipping release notes"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! To release, push the tag:"
|
||||
if [[ "$MADE_COMMIT" == "true" ]]; then
|
||||
echo " git push origin $CURRENT_BRANCH"
|
||||
fi
|
||||
echo " git push origin $TAG"
|
||||
echo ""
|
||||
echo "Or to undo:"
|
||||
if [[ "$MADE_COMMIT" == "true" ]]; then
|
||||
echo " git reset --hard HEAD~1 && git tag -d $TAG"
|
||||
else
|
||||
echo " git tag -d $TAG"
|
||||
fi
|
||||
if [[ "$GH_RELEASE_CREATED" == "true" ]]; then
|
||||
echo ""
|
||||
echo "Note: GitHub release was created automatically with changelog notes."
|
||||
fi
|
||||
else
|
||||
# Feature branch: skip tagging, instruct user on next steps
|
||||
echo ""
|
||||
echo "Done! Version updated and committed (tag deferred)."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Push your branch for CI validation:"
|
||||
echo " git push origin $CURRENT_BRANCH"
|
||||
echo ""
|
||||
echo " 2. After CI passes and PR is merged to main, create the tag and release:"
|
||||
echo " git checkout main && git pull"
|
||||
echo " git tag -a '$TAG' $COMMIT_SHA -m '$SKILL_NAME version $VERSION'"
|
||||
echo " git push origin $TAG"
|
||||
if [ -f "$SKILL_PATH/CHANGELOG.md" ]; then
|
||||
echo " # Create GitHub release with changelog (requires GitHub CLI):"
|
||||
echo " gh release create '$TAG' --title '$SKILL_NAME v$VERSION' --generate-notes"
|
||||
fi
|
||||
echo ""
|
||||
echo "Or to undo the version bump:"
|
||||
echo " git reset --hard HEAD~1"
|
||||
fi
|
||||
@@ -1,262 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# validate-release-links.sh
|
||||
# Validates that all links referenced in SKILL.md files and READMEs will be
|
||||
# available after release based on the skill-release.yml workflow logic.
|
||||
#
|
||||
# Usage: ./scripts/validate-release-links.sh [skill-name]
|
||||
# If skill-name is provided, only validates that skill
|
||||
# Otherwise validates all skills
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SKILLS_DIR="$PROJECT_ROOT/skills"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
ERRORS=0
|
||||
# shellcheck disable=SC2034 # WARNINGS reserved for future use
|
||||
WARNINGS=0
|
||||
|
||||
# Get repo info from git remote
|
||||
REPO=$(git -C "$PROJECT_ROOT" remote get-url origin 2>/dev/null | sed -E 's|.*github.com[:/]||' | sed 's/.git$//' || echo "prompt-security/ClawSec")
|
||||
echo -e "${BLUE}Repository: $REPO${NC}"
|
||||
echo ""
|
||||
|
||||
# Function to get files that will be in a release
|
||||
get_release_assets() {
|
||||
local skill_path="$1"
|
||||
local skill_name="$2"
|
||||
local assets=()
|
||||
|
||||
# Files from SBOM
|
||||
if [ -f "$skill_path/skill.json" ]; then
|
||||
while IFS= read -r file; do
|
||||
assets+=("$(basename "$file")")
|
||||
done < <(jq -r '.sbom.files[].path // empty' "$skill_path/skill.json" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Always included
|
||||
assets+=("skill.json")
|
||||
assets+=("checksums.json")
|
||||
|
||||
# README if exists
|
||||
if [ -f "$skill_path/README.md" ]; then
|
||||
assets+=("README.md")
|
||||
fi
|
||||
|
||||
printf '%s\n' "${assets[@]}" | sort -u
|
||||
}
|
||||
|
||||
# Function to extract expected files from documentation
|
||||
extract_referenced_files() {
|
||||
local file="$1"
|
||||
local skill_name="$2"
|
||||
|
||||
# Extract filenames from download URLs matching this skill
|
||||
grep -oE "releases/(latest/)?download/[^/]+/[^\"'\`\s)]+" "$file" 2>/dev/null | \
|
||||
grep -E "/${skill_name}-v|/latest/" | \
|
||||
sed -E 's|.*/||' | \
|
||||
sort -u || true
|
||||
}
|
||||
|
||||
# Function to extract all referenced files from any download URL
|
||||
extract_all_referenced_files() {
|
||||
local file="$1"
|
||||
|
||||
# Extract all filenames from download URLs
|
||||
grep -oE "releases/(latest/)?download/[^/]+/[a-zA-Z0-9_.-]+" "$file" 2>/dev/null | \
|
||||
sed -E 's|.*/||' | \
|
||||
sort -u || true
|
||||
}
|
||||
|
||||
validate_skill() {
|
||||
local skill_name="$1"
|
||||
local skill_path="$SKILLS_DIR/$skill_name"
|
||||
local skill_errors=0
|
||||
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}Validating: ${skill_name}${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
|
||||
# Check skill.json exists
|
||||
if [ ! -f "$skill_path/skill.json" ]; then
|
||||
echo -e "${RED} ✗ Missing skill.json${NC}"
|
||||
((ERRORS++))
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Get version from skill.json
|
||||
VERSION=$(jq -r '.version // "unknown"' "$skill_path/skill.json")
|
||||
echo -e " Version: $VERSION"
|
||||
echo -e " Tag will be: ${skill_name}-v${VERSION}"
|
||||
echo ""
|
||||
|
||||
# Get assets that will be created by workflow
|
||||
echo -e "${YELLOW} Assets that will be created:${NC}"
|
||||
RELEASE_ASSETS=()
|
||||
while IFS= read -r asset; do
|
||||
RELEASE_ASSETS+=("$asset")
|
||||
echo -e " ✓ $asset"
|
||||
done < <(get_release_assets "$skill_path" "$skill_name")
|
||||
echo ""
|
||||
|
||||
# Verify SBOM files exist locally
|
||||
echo -e "${YELLOW} Verifying SBOM files exist:${NC}"
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$skill_path/$file" ]; then
|
||||
echo -e " ${GREEN}✓${NC} $file"
|
||||
else
|
||||
echo -e " ${RED}✗ MISSING: $file${NC}"
|
||||
((skill_errors++))
|
||||
((ERRORS++))
|
||||
fi
|
||||
done < <(jq -r '.sbom.files[].path // empty' "$skill_path/skill.json" 2>/dev/null)
|
||||
echo ""
|
||||
|
||||
# Check links in SKILL.md
|
||||
if [ -f "$skill_path/SKILL.md" ]; then
|
||||
echo -e "${YELLOW} Checking SKILL.md references:${NC}"
|
||||
|
||||
# Find files referenced for THIS skill specifically
|
||||
while IFS= read -r referenced_file; do
|
||||
[ -z "$referenced_file" ] && continue
|
||||
|
||||
# Check if this file will be in the release
|
||||
found=false
|
||||
for asset in "${RELEASE_ASSETS[@]}"; do
|
||||
if [ "$asset" = "$referenced_file" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" = true ]; then
|
||||
echo -e " ${GREEN}✓${NC} $referenced_file (will be available)"
|
||||
else
|
||||
# Check if it's a reference to another skill's release
|
||||
if grep -qE "/${skill_name}-v[0-9]" "$skill_path/SKILL.md" 2>/dev/null || \
|
||||
grep -q "/latest/download/$referenced_file" "$skill_path/SKILL.md" 2>/dev/null; then
|
||||
echo -e " ${RED}✗${NC} $referenced_file (NOT in SBOM - won't be released)"
|
||||
((skill_errors++))
|
||||
((ERRORS++))
|
||||
fi
|
||||
fi
|
||||
done < <(extract_all_referenced_files "$skill_path/SKILL.md")
|
||||
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check links in README.md
|
||||
if [ -f "$skill_path/README.md" ]; then
|
||||
echo -e "${YELLOW} Checking README.md references:${NC}"
|
||||
|
||||
while IFS= read -r referenced_file; do
|
||||
[ -z "$referenced_file" ] && continue
|
||||
|
||||
found=false
|
||||
for asset in "${RELEASE_ASSETS[@]}"; do
|
||||
if [ "$asset" = "$referenced_file" ]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" = true ]; then
|
||||
echo -e " ${GREEN}✓${NC} $referenced_file"
|
||||
else
|
||||
# Only error if it's referencing THIS skill's release
|
||||
if grep -qE "/${skill_name}-v|/latest/download/${referenced_file}" "$skill_path/README.md" 2>/dev/null; then
|
||||
echo -e " ${RED}✗${NC} $referenced_file (NOT in release assets)"
|
||||
((skill_errors++))
|
||||
((ERRORS++))
|
||||
fi
|
||||
fi
|
||||
done < <(extract_all_referenced_files "$skill_path/README.md")
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Cross-reference check: look for this skill being referenced by OTHER skills
|
||||
echo -e "${YELLOW} Cross-references from other skills:${NC}"
|
||||
cross_refs_found=false
|
||||
for other_skill_dir in "$SKILLS_DIR"/*/; do
|
||||
other_skill=$(basename "$other_skill_dir")
|
||||
[ "$other_skill" = "$skill_name" ] && continue
|
||||
|
||||
for doc in "$other_skill_dir"/*.md; do
|
||||
[ -f "$doc" ] || continue
|
||||
|
||||
if grep -qE "/${skill_name}-v" "$doc" 2>/dev/null; then
|
||||
echo -e " → Referenced by ${other_skill}/$(basename "$doc")"
|
||||
cross_refs_found=true
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$cross_refs_found" = false ]; then
|
||||
echo -e " (none found)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Summary for this skill
|
||||
if [ $skill_errors -eq 0 ]; then
|
||||
echo -e "${GREEN} ✓ All checks passed for ${skill_name}${NC}"
|
||||
else
|
||||
echo -e "${RED} ✗ ${skill_errors} issue(s) found for ${skill_name}${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
return $skill_errors
|
||||
}
|
||||
|
||||
# Main logic
|
||||
if [ $# -gt 0 ]; then
|
||||
# Validate specific skill
|
||||
if [ -d "$SKILLS_DIR/$1" ]; then
|
||||
validate_skill "$1"
|
||||
else
|
||||
echo -e "${RED}Error: Skill '$1' not found in $SKILLS_DIR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Validate all skills
|
||||
echo -e "${BLUE}Scanning all skills in $SKILLS_DIR${NC}"
|
||||
echo ""
|
||||
|
||||
for skill_dir in "$SKILLS_DIR"/*/; do
|
||||
skill_name=$(basename "$skill_dir")
|
||||
|
||||
# Skip if no skill.json
|
||||
[ -f "$skill_dir/skill.json" ] || continue
|
||||
|
||||
validate_skill "$skill_name" || true
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}SUMMARY${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
|
||||
if [ $ERRORS -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All validations passed!${NC}"
|
||||
echo ""
|
||||
echo "Release URLs will follow this pattern:"
|
||||
echo " https://github.com/$REPO/releases/download/{skill-name}-v{version}/{file}"
|
||||
echo ""
|
||||
echo "For 'latest' symlinks:"
|
||||
echo " https://github.com/$REPO/releases/latest/download/{file}"
|
||||
echo " (Note: 'latest' points to the most recent release of ANY skill)"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Found $ERRORS error(s)${NC}"
|
||||
echo ""
|
||||
echo "Please fix the issues above before tagging a release."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,12 +0,0 @@
|
||||
# Exclude local caches and build outputs from ClawHub upload
|
||||
.DS_Store
|
||||
.git/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.venv/
|
||||
.cache/
|
||||
@@ -1,165 +0,0 @@
|
||||
---
|
||||
name: claw-release
|
||||
version: 0.0.1
|
||||
description: Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"🚀","category":"utility","internal":true}}
|
||||
clawdis:
|
||||
emoji: "🚀"
|
||||
requires:
|
||||
bins: [git, jq, gh]
|
||||
---
|
||||
|
||||
# Claw Release
|
||||
|
||||
Internal tool for releasing skills and managing the ClawSec catalog.
|
||||
|
||||
**An internal tool by [Prompt Security](https://prompt.security)**
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Release Type | Command | Tag Format |
|
||||
|-------------|---------|------------|
|
||||
| Skill release | `./scripts/release-skill.sh <name> <version>` | `<name>-v<version>` |
|
||||
| Pre-release | `./scripts/release-skill.sh <name> 1.0.0-beta1` | `<name>-v1.0.0-beta1` |
|
||||
|
||||
---
|
||||
|
||||
## Release Workflow
|
||||
|
||||
### Step 1: Determine Version Type
|
||||
|
||||
Ask what changed:
|
||||
- **Bug fixes only** → Patch (1.0.0 → 1.0.1)
|
||||
- **New features, backward compatible** → Minor (1.0.0 → 1.1.0)
|
||||
- **Breaking changes** → Major (1.0.0 → 2.0.0)
|
||||
- **Testing/unstable** → Pre-release (1.0.0-beta1, 1.0.0-rc1)
|
||||
|
||||
### Step 2: Pre-flight Checks
|
||||
|
||||
```bash
|
||||
# Check for uncommitted changes
|
||||
git status
|
||||
|
||||
# Verify skill directory exists
|
||||
ls skills/<skill-name>/skill.json
|
||||
|
||||
# Get current version
|
||||
jq -r '.version' skills/<skill-name>/skill.json
|
||||
```
|
||||
|
||||
### Step 3: Run Release Script
|
||||
|
||||
```bash
|
||||
./scripts/release-skill.sh <skill-name> <new-version>
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Validate version format (semver)
|
||||
2. Check tag doesn't already exist
|
||||
3. Update skill.json version
|
||||
4. Update SKILL.md frontmatter version (if file exists)
|
||||
5. Update hardcoded version URLs (feed_url)
|
||||
6. Commit changes
|
||||
7. Create annotated git tag
|
||||
|
||||
### Step 4: Push Release
|
||||
|
||||
```bash
|
||||
git push && git push origin <skill-name>-v<version>
|
||||
```
|
||||
|
||||
### Step 5: Verify Release
|
||||
|
||||
After pushing, the CI/CD pipeline will:
|
||||
1. Validate skill exists
|
||||
2. Verify version matches skill.json
|
||||
3. Verify version matches SKILL.md frontmatter (if exists)
|
||||
4. Generate checksums from SBOM
|
||||
5. Create .skill package (ZIP)
|
||||
6. Create GitHub Release
|
||||
7. Trigger website rebuild (for non-internal skills)
|
||||
|
||||
Verify at:
|
||||
- **GitHub Releases:** `https://github.com/prompt-security/clawsec/releases/tag/<skill-name>-v<version>`
|
||||
- **GitHub Actions:** Check workflow run status
|
||||
|
||||
---
|
||||
|
||||
## Undo a Release (Before Push)
|
||||
|
||||
If you need to undo before pushing:
|
||||
|
||||
```bash
|
||||
git reset --hard HEAD~1 && git tag -d <skill-name>-v<version>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-release Versions
|
||||
|
||||
For beta, alpha, or release candidates:
|
||||
|
||||
```bash
|
||||
./scripts/release-skill.sh <skill-name> 1.2.0-beta1
|
||||
./scripts/release-skill.sh <skill-name> 1.2.0-alpha1
|
||||
./scripts/release-skill.sh <skill-name> 1.2.0-rc1
|
||||
```
|
||||
|
||||
Pre-releases are automatically marked in GitHub Releases.
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| `Tag already exists` | Choose a different version number |
|
||||
| `Version mismatch in CI` | Ensure you used the release script (not manual tagging) |
|
||||
| `SKILL.md version mismatch` | Ensure you used the release script which updates both skill.json and SKILL.md |
|
||||
| `Uncommitted changes` | Commit or stash first: `git stash` or `git add . && git commit` |
|
||||
| `skill.json not found` | Verify skill directory path is correct |
|
||||
|
||||
---
|
||||
|
||||
## Internal Skills
|
||||
|
||||
Skills with `"internal": true` in their `openclaw` section:
|
||||
- Are released normally via GitHub Releases
|
||||
- Are NOT shown in the public skills catalog website
|
||||
- Can still be downloaded directly from release URLs
|
||||
|
||||
This skill (`claw-release`) is an internal skill.
|
||||
|
||||
---
|
||||
|
||||
## Existing Skills
|
||||
|
||||
| Skill | Category | Internal |
|
||||
|-------|----------|----------|
|
||||
| clawsec-feed | security | No |
|
||||
| clawtributor | security | No |
|
||||
| openclaw-audit-watchdog | security | No |
|
||||
| soul-guardian | security | No |
|
||||
| claw-release | utility | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After release, confirm:
|
||||
- [ ] GitHub Release exists with correct tag
|
||||
- [ ] Release has: skill.json, SKILL.md, checksums.json, .skill package
|
||||
- [ ] Release is marked as pre-release if applicable
|
||||
- [ ] GitHub Actions workflow completed successfully
|
||||
- [ ] Website updated (for non-internal skills only)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See repository for details.
|
||||
|
||||
Built by the [Prompt Security](https://prompt.security) team.
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "claw-release",
|
||||
"version": "0.0.1",
|
||||
"description": "Release automation for Claw skills and website. Guides through version bumping, tagging, and release verification.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"homepage": "https://clawsec.prompt.security",
|
||||
"keywords": ["release", "versioning", "deployment", "automation", "ci-cd", "skills"],
|
||||
|
||||
"sbom": {
|
||||
"files": [
|
||||
{ "path": "SKILL.md", "required": true, "description": "Release workflow guide" }
|
||||
]
|
||||
},
|
||||
|
||||
"openclaw": {
|
||||
"emoji": "🚀",
|
||||
"category": "utility",
|
||||
"internal": true,
|
||||
"requires": { "bins": ["git", "jq", "gh"] },
|
||||
"triggers": [
|
||||
"release skill",
|
||||
"create release",
|
||||
"bump version",
|
||||
"tag release",
|
||||
"publish skill",
|
||||
"claw release",
|
||||
"deploy skill",
|
||||
"new version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# Exclude local caches and build outputs from ClawHub upload
|
||||
.DS_Store
|
||||
.git/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.venv/
|
||||
.cache/
|
||||
@@ -1,65 +0,0 @@
|
||||
# ClawSec Feed 📡
|
||||
|
||||
Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence and stay informed about emerging threats.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Advisories** - Get notified about malicious skills, vulnerabilities, and attack patterns
|
||||
- **Cross-Reference Detection** - Automatically checks if your installed skills are affected
|
||||
- **Community-Driven** - Advisories contributed and reviewed by the security community
|
||||
- **Heartbeat Integration** - Seamlessly integrates with your agent's routine checks
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -sLO https://github.com/prompt-security/clawsec/releases/latest/download/clawsec-feed.skill
|
||||
```
|
||||
|
||||
## Advisory Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `malicious_skill` | Skills identified as intentionally harmful |
|
||||
| `vulnerable_skill` | Skills with security vulnerabilities |
|
||||
| `prompt_injection` | Known prompt injection patterns |
|
||||
| `attack_pattern` | Observed attack techniques |
|
||||
|
||||
## Feed Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"updated": "2026-02-02T12:00:00Z",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "GA-2026-001",
|
||||
"severity": "critical",
|
||||
"type": "malicious_skill",
|
||||
"title": "Data exfiltration in 'helper-plus'",
|
||||
"affected": ["helper-plus@1.0.0"],
|
||||
"action": "Remove immediately"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Response Example
|
||||
|
||||
```
|
||||
📡 ClawSec Feed: 2 new advisories
|
||||
|
||||
CRITICAL - GA-2026-015: Malicious prompt pattern
|
||||
→ Update your system prompt defenses.
|
||||
|
||||
HIGH - GA-2026-016: Vulnerable skill "data-helper"
|
||||
→ You have this installed! Update to v1.2.1
|
||||
```
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **openclaw-audit-watchdog** - Automated daily security audits
|
||||
- **clawtributor** - Report vulnerabilities to the community
|
||||
|
||||
## License
|
||||
|
||||
MIT License - [Prompt Security](https://prompt.security)
|
||||
@@ -1,676 +0,0 @@
|
||||
---
|
||||
name: clawsec-feed
|
||||
version: 0.0.4
|
||||
description: Security advisory feed with automated NVD CVE polling for OpenClaw-related vulnerabilities. Updated daily.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"📡","category":"security"}}
|
||||
clawdis:
|
||||
emoji: "📡"
|
||||
requires:
|
||||
bins: [curl, jq]
|
||||
---
|
||||
|
||||
# ClawSec Feed 📡
|
||||
|
||||
Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence and stay informed about emerging threats.
|
||||
|
||||
This feed is automatically updated daily with CVEs related to OpenClaw, clawdbot, and Moltbot from the NIST National Vulnerability Database (NVD).
|
||||
|
||||
**An open source project by [Prompt Security](https://prompt.security)**
|
||||
|
||||
---
|
||||
|
||||
## Installation Options
|
||||
|
||||
You can get clawsec-feed in two ways:
|
||||
|
||||
### Option A: Bundled with ClawSec Suite (Recommended)
|
||||
|
||||
**If you've installed clawsec-suite, you may already have this!**
|
||||
|
||||
ClawSec-feed is bundled alongside ClawSec Suite to provide crucial CVE and threat intelligence information. When you install the suite, if you don't already have the feed installed, it will be deployed from the bundled copy.
|
||||
|
||||
**Advantages:**
|
||||
- Convenient - no separate download needed
|
||||
- Standard location - installed to `~/.openclaw/skills/clawsec-feed/`
|
||||
- Preserved - if you already have feed installed, it won't be overwritten
|
||||
- Single verification - integrity checked as part of suite package
|
||||
|
||||
### Option B: Standalone Installation (This Page)
|
||||
|
||||
Install clawsec-feed independently without the full suite.
|
||||
|
||||
**When to use standalone:**
|
||||
- You only need the advisory feed (not other suite components)
|
||||
- You want to install before installing the suite
|
||||
- You prefer explicit control over feed installation
|
||||
|
||||
**Advantages:**
|
||||
- Lighter weight installation
|
||||
- Independent from suite
|
||||
- Direct control over installation process
|
||||
|
||||
Continue below for standalone installation instructions.
|
||||
|
||||
---
|
||||
|
||||
Installation Steps:
|
||||
Agents should review scripts before execution!
|
||||
|
||||
|
||||
```bash
|
||||
# Get latest clawsec-feed release tag
|
||||
LATEST_TAG=$(curl -sSL https://api.github.com/repos/prompt-security/ClawSec/releases | \
|
||||
jq -r '[.[] | select(.tag_name | startswith("clawsec-feed-v"))][0].tag_name')
|
||||
|
||||
# Download SKILL.md from latest release
|
||||
SKILL_URL="https://github.com/prompt-security/clawsec/releases/download/$LATEST_TAG/SKILL.md"
|
||||
curl -sSL --fail --show-error "$SKILL_URL"
|
||||
```
|
||||
|
||||
Once you have this skill file, proceed to **[Deploy ClawSec Feed](#deploy-clawsec-feed)** below.
|
||||
|
||||
---
|
||||
|
||||
## Deploy ClawSec Feed
|
||||
|
||||
Installation steps:
|
||||
|
||||
### Step 1: Create skill directory and save this file
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.openclaw/skills/clawsec-feed
|
||||
# Save this SKILL.md as SKILL.md in the directory above
|
||||
```
|
||||
|
||||
### Step 2: Install skill files
|
||||
|
||||
```bash
|
||||
# Get latest release tag with retry logic
|
||||
LATEST_TAG=$(curl -sSL --retry 3 --retry-delay 1 \
|
||||
https://api.github.com/repos/prompt-security/ClawSec/releases | \
|
||||
jq -r '[.[] | select(.tag_name | startswith("clawsec-feed-v"))][0].tag_name')
|
||||
|
||||
BASE_URL="https://github.com/prompt-security/clawsec/releases/download/$LATEST_TAG"
|
||||
INSTALL_DIR="${CLAWSEC_INSTALL_DIR:-$HOME/.openclaw/skills/clawsec-feed}"
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf '$TEMP_DIR'" EXIT
|
||||
|
||||
# Download checksums.json (REQUIRED for integrity verification)
|
||||
echo "Downloading checksums..."
|
||||
if ! curl -sSL --fail --show-error --retry 3 --retry-delay 1 \
|
||||
"$BASE_URL/checksums.json" -o "$TEMP_DIR/checksums.json"; then
|
||||
echo "ERROR: Failed to download checksums.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate checksums.json structure
|
||||
if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: Invalid checksums.json structure"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# PRIMARY: Try .skill artifact
|
||||
echo "Attempting .skill artifact installation..."
|
||||
if curl -sSL --fail --show-error --retry 3 --retry-delay 1 \
|
||||
"$BASE_URL/clawsec-feed.skill" -o "$TEMP_DIR/clawsec-feed.skill" 2>/dev/null; then
|
||||
|
||||
# Security: Check artifact size (prevent DoS)
|
||||
ARTIFACT_SIZE=$(stat -c%s "$TEMP_DIR/clawsec-feed.skill" 2>/dev/null || stat -f%z "$TEMP_DIR/clawsec-feed.skill")
|
||||
MAX_SIZE=$((50 * 1024 * 1024)) # 50MB
|
||||
|
||||
if [ "$ARTIFACT_SIZE" -gt "$MAX_SIZE" ]; then
|
||||
echo "WARNING: Artifact too large ($(( ARTIFACT_SIZE / 1024 / 1024 ))MB), falling back to individual files"
|
||||
else
|
||||
echo "Extracting artifact ($(( ARTIFACT_SIZE / 1024 ))KB)..."
|
||||
|
||||
# Security: Check for path traversal before extraction
|
||||
if unzip -l "$TEMP_DIR/clawsec-feed.skill" | grep -qE '\.\./|^/|~/'; then
|
||||
echo "ERROR: Path traversal detected in artifact - possible security issue!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Security: Check file count (prevent zip bomb)
|
||||
FILE_COUNT=$(unzip -l "$TEMP_DIR/clawsec-feed.skill" | grep -c "^[[:space:]]*[0-9]" || echo 0)
|
||||
if [ "$FILE_COUNT" -gt 100 ]; then
|
||||
echo "ERROR: Artifact contains too many files ($FILE_COUNT) - possible zip bomb"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract to temp directory
|
||||
unzip -q "$TEMP_DIR/clawsec-feed.skill" -d "$TEMP_DIR/extracted"
|
||||
|
||||
# Verify skill.json exists
|
||||
if [ ! -f "$TEMP_DIR/extracted/clawsec-feed/skill.json" ]; then
|
||||
echo "ERROR: skill.json not found in artifact"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify checksums for all extracted files
|
||||
echo "Verifying checksums..."
|
||||
CHECKSUM_FAILED=0
|
||||
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
|
||||
EXPECTED=$(jq -r --arg f "$file" '.files[$f].sha256' "$TEMP_DIR/checksums.json")
|
||||
FILE_PATH=$(jq -r --arg f "$file" '.files[$f].path' "$TEMP_DIR/checksums.json")
|
||||
|
||||
# Try nested path first, then flat filename
|
||||
if [ -f "$TEMP_DIR/extracted/clawsec-feed/$FILE_PATH" ]; then
|
||||
ACTUAL=$(shasum -a 256 "$TEMP_DIR/extracted/clawsec-feed/$FILE_PATH" | cut -d' ' -f1)
|
||||
elif [ -f "$TEMP_DIR/extracted/clawsec-feed/$file" ]; then
|
||||
ACTUAL=$(shasum -a 256 "$TEMP_DIR/extracted/clawsec-feed/$file" | cut -d' ' -f1)
|
||||
else
|
||||
echo " ✗ $file (not found in artifact)"
|
||||
CHECKSUM_FAILED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo " ✗ $file (checksum mismatch)"
|
||||
CHECKSUM_FAILED=1
|
||||
else
|
||||
echo " ✓ $file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$CHECKSUM_FAILED" -eq 0 ]; then
|
||||
# Validate feed.json structure (skill-specific)
|
||||
if [ -f "$TEMP_DIR/extracted/clawsec-feed/advisories/feed.json" ]; then
|
||||
FEED_FILE="$TEMP_DIR/extracted/clawsec-feed/advisories/feed.json"
|
||||
elif [ -f "$TEMP_DIR/extracted/clawsec-feed/feed.json" ]; then
|
||||
FEED_FILE="$TEMP_DIR/extracted/clawsec-feed/feed.json"
|
||||
else
|
||||
echo "ERROR: feed.json not found in artifact"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.version and .advisories' "$FEED_FILE" >/dev/null 2>&1; then
|
||||
echo "ERROR: feed.json missing required fields (version, advisories)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# SUCCESS: Install from artifact
|
||||
echo "Installing from artifact..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp -r "$TEMP_DIR/extracted/clawsec-feed"/* "$INSTALL_DIR/"
|
||||
chmod 600 "$INSTALL_DIR/skill.json"
|
||||
find "$INSTALL_DIR" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||
echo "SUCCESS: Skill installed from .skill artifact"
|
||||
exit 0
|
||||
else
|
||||
echo "WARNING: Checksum verification failed, falling back to individual files"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# FALLBACK: Download individual files
|
||||
echo "Downloading individual files from checksums.json manifest..."
|
||||
mkdir -p "$TEMP_DIR/downloads"
|
||||
|
||||
DOWNLOAD_FAILED=0
|
||||
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
|
||||
FILE_URL=$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")
|
||||
EXPECTED=$(jq -r --arg f "$file" '.files[$f].sha256' "$TEMP_DIR/checksums.json")
|
||||
|
||||
echo "Downloading: $file"
|
||||
if ! curl -sSL --fail --show-error --retry 3 --retry-delay 1 \
|
||||
"$FILE_URL" -o "$TEMP_DIR/downloads/$file"; then
|
||||
echo "ERROR: Failed to download $file"
|
||||
DOWNLOAD_FAILED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# Verify checksum immediately
|
||||
ACTUAL=$(shasum -a 256 "$TEMP_DIR/downloads/$file" | cut -d' ' -f1)
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "ERROR: Checksum mismatch for $file"
|
||||
DOWNLOAD_FAILED=1
|
||||
else
|
||||
echo " ✓ Verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DOWNLOAD_FAILED" -eq 1 ]; then
|
||||
echo "ERROR: Individual file download failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate JSON files structure
|
||||
if ! jq -e '.name and .version' "$TEMP_DIR/downloads/skill.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: skill.json missing required fields (name, version)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.version and .advisories' "$TEMP_DIR/downloads/feed.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: feed.json missing required fields (version, advisories)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install files
|
||||
echo "Installing from individual files..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp "$TEMP_DIR/downloads"/* "$INSTALL_DIR/"
|
||||
chmod 600 "$INSTALL_DIR/skill.json"
|
||||
find "$INSTALL_DIR" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||
echo "SUCCESS: Skill installed from individual files"
|
||||
```
|
||||
|
||||
### Step 3: Add to your heartbeat
|
||||
|
||||
Add ClawSec Feed to your heartbeat routine:
|
||||
|
||||
```markdown
|
||||
## ClawSec Feed (every heartbeat)
|
||||
1. Check advisory feed for new security alerts
|
||||
2. Cross-reference with installed skills
|
||||
3. Notify user if action is required
|
||||
```
|
||||
|
||||
### Step 4: Confirm deployment
|
||||
|
||||
Tell your user:
|
||||
```
|
||||
ClawSec Feed deployed successfully. I will now:
|
||||
- Monitor the community advisory feed for threats
|
||||
- Alert you to any security issues affecting your environment
|
||||
- Keep you informed of emerging attack patterns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What ClawSec Feed Does
|
||||
|
||||
### Advisory Feed Monitoring
|
||||
|
||||
Subscribes to the community advisory feed for:
|
||||
|
||||
- **Known malicious skills/plugins** - Skills that have been identified as harmful
|
||||
- **Prompt injection patterns** - Attack patterns observed in the wild
|
||||
- **Vulnerable skill versions** - Skills with known security flaws
|
||||
- **Security best practice updates** - New recommendations for agent safety
|
||||
|
||||
When a relevant advisory is published, your agent will notify you.
|
||||
|
||||
---
|
||||
|
||||
## Checking the Advisory Feed
|
||||
|
||||
```bash
|
||||
# Use environment variable if set, otherwise use raw GitHub feed (always up-to-date)
|
||||
DEFAULT_FEED_URL="https://raw.githubusercontent.com/prompt-security/ClawSec/main/advisories/feed.json"
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-$DEFAULT_FEED_URL}"
|
||||
|
||||
# Fetch with error handling and retry logic
|
||||
curl -sSL --fail --show-error --retry 3 --retry-delay 1 "$FEED_URL"
|
||||
```
|
||||
|
||||
**Feed structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"updated": "2026-02-02T12:00:00Z",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "GA-2026-001",
|
||||
"severity": "critical",
|
||||
"type": "malicious_skill",
|
||||
"title": "Malicious data exfiltration in skill 'helper-plus'",
|
||||
"description": "Skill sends user data to external server",
|
||||
"affected": ["helper-plus@1.0.0", "helper-plus@1.0.1"],
|
||||
"action": "Remove immediately",
|
||||
"published": "2026-02-01T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parsing the Feed
|
||||
|
||||
### Get advisory count
|
||||
|
||||
```bash
|
||||
# Use environment variable if set, otherwise use raw GitHub feed (always up-to-date)
|
||||
DEFAULT_FEED_URL="https://raw.githubusercontent.com/prompt-security/ClawSec/main/advisories/feed.json"
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-$DEFAULT_FEED_URL}"
|
||||
|
||||
TEMP_FEED=$(mktemp)
|
||||
trap "rm -f '$TEMP_FEED'" EXIT
|
||||
|
||||
if ! curl -sSL --fail --show-error --retry 3 --retry-delay 1 "$FEED_URL" -o "$TEMP_FEED"; then
|
||||
echo "Error: Failed to fetch advisory feed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate JSON before parsing
|
||||
if ! jq empty "$TEMP_FEED" 2>/dev/null; then
|
||||
echo "Error: Invalid JSON in feed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FEED=$(cat "$TEMP_FEED")
|
||||
|
||||
# Get advisory count with error handling
|
||||
COUNT=$(echo "$FEED" | jq '.advisories | length')
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to parse advisories"
|
||||
exit 1
|
||||
fi
|
||||
echo "Advisory count: $COUNT"
|
||||
```
|
||||
|
||||
### Get critical advisories
|
||||
|
||||
```bash
|
||||
# Parse critical advisories with jq error handling
|
||||
CRITICAL=$(echo "$FEED" | jq '.advisories[] | select(.severity == "critical")')
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to filter critical advisories"
|
||||
exit 1
|
||||
fi
|
||||
echo "$CRITICAL"
|
||||
```
|
||||
|
||||
### Get advisories from the last 7 days
|
||||
|
||||
```bash
|
||||
# Use UTC timezone for consistent date handling
|
||||
WEEK_AGO=$(TZ=UTC date -v-7d +%Y-%m-%dT00:00:00Z 2>/dev/null || TZ=UTC date -d '7 days ago' +%Y-%m-%dT00:00:00Z)
|
||||
RECENT=$(echo "$FEED" | jq --arg since "$WEEK_AGO" '.advisories[] | select(.published > $since)')
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to filter recent advisories"
|
||||
exit 1
|
||||
fi
|
||||
echo "$RECENT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Reference Installed Skills
|
||||
|
||||
Check if any of your installed skills are affected by advisories:
|
||||
|
||||
```bash
|
||||
# List your installed skills (adjust path for your platform)
|
||||
INSTALL_DIR="${CLAWSEC_INSTALL_DIR:-$HOME/.openclaw/skills}"
|
||||
|
||||
# Use environment variable if set, otherwise use raw GitHub feed (always up-to-date)
|
||||
DEFAULT_FEED_URL="https://raw.githubusercontent.com/prompt-security/ClawSec/main/advisories/feed.json"
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-$DEFAULT_FEED_URL}"
|
||||
|
||||
TEMP_FEED=$(mktemp)
|
||||
trap "rm -f '$TEMP_FEED'" EXIT
|
||||
|
||||
if ! curl -sSL --fail --show-error --retry 3 --retry-delay 1 "$FEED_URL" -o "$TEMP_FEED"; then
|
||||
echo "Error: Failed to fetch advisory feed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate and parse feed
|
||||
if ! jq empty "$TEMP_FEED" 2>/dev/null; then
|
||||
echo "Error: Invalid JSON in feed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FEED=$(cat "$TEMP_FEED")
|
||||
AFFECTED=$(echo "$FEED" | jq -r '.advisories[].affected[]?' 2>/dev/null | sort -u)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to parse affected skills from feed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safely validate all installed skills before processing
|
||||
# This prevents shell injection via malicious filenames
|
||||
VALIDATED_SKILLS=()
|
||||
while IFS= read -r -d '' skill_path; do
|
||||
skill=$(basename "$skill_path")
|
||||
|
||||
# Validate skill name BEFORE adding to array (prevents injection)
|
||||
if [[ "$skill" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
VALIDATED_SKILLS+=("$skill")
|
||||
else
|
||||
echo "Warning: Skipping invalid skill name: $skill" >&2
|
||||
fi
|
||||
done < <(find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null)
|
||||
|
||||
# Check each validated skill against affected list
|
||||
# Use grep -qF for fixed string matching (prevents regex injection)
|
||||
for skill in "${VALIDATED_SKILLS[@]}"; do
|
||||
# At this point, $skill is guaranteed to match ^[a-zA-Z0-9_-]+$
|
||||
if echo "$AFFECTED" | grep -qF "$skill"; then
|
||||
echo "WARNING: Installed skill '$skill' has a security advisory!"
|
||||
# Get advisory details for this skill
|
||||
echo "$FEED" | jq --arg s "$skill" '.advisories[] | select(.affected[] | contains($s))'
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**If you find affected skills:**
|
||||
1. Check the advisory for details and severity
|
||||
2. Notify your user immediately for critical/high severity
|
||||
3. Include the recommended action from the advisory
|
||||
|
||||
---
|
||||
|
||||
## Advisory Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `malicious_skill` | Skill identified as intentionally harmful |
|
||||
| `vulnerable_skill` | Skill with security vulnerabilities |
|
||||
| `prompt_injection` | Known prompt injection pattern |
|
||||
| `attack_pattern` | Observed attack technique |
|
||||
| `best_practice` | Security recommendation |
|
||||
|
||||
---
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Severity | Action Required |
|
||||
|----------|-----------------|
|
||||
| `critical` | Notify user immediately, take action |
|
||||
| `high` | Notify user soon, plan remediation |
|
||||
| `medium` | Notify at next interaction |
|
||||
| `low` | Log for reference |
|
||||
|
||||
---
|
||||
|
||||
## When to Notify Your User
|
||||
|
||||
**Notify Immediately (Critical):**
|
||||
- New critical advisory affecting an installed skill
|
||||
- Active exploitation detected
|
||||
|
||||
**Notify Soon (High):**
|
||||
- New high-severity advisory affecting installed skills
|
||||
- Failed to fetch advisory feed (network issue?)
|
||||
|
||||
**Notify at Next Interaction (Medium):**
|
||||
- New medium-severity advisories
|
||||
- General security updates
|
||||
|
||||
**Log Only (Low/Info):**
|
||||
- Low-severity advisories (mention if user asks)
|
||||
- Feed checked, no new advisories
|
||||
|
||||
---
|
||||
|
||||
## Response Format
|
||||
|
||||
### If there are new advisories:
|
||||
|
||||
```
|
||||
📡 ClawSec Feed: 2 new advisories since last check
|
||||
|
||||
CRITICAL - GA-2026-015: Malicious prompt pattern "ignore-all"
|
||||
→ Detected prompt injection technique. Update your system prompt defenses.
|
||||
|
||||
HIGH - GA-2026-016: Vulnerable skill "data-helper" v1.2.0
|
||||
→ You have this installed! Recommended action: Update to v1.2.1 or remove.
|
||||
```
|
||||
|
||||
### If nothing new:
|
||||
|
||||
```
|
||||
FEED_OK - Advisory feed checked, no new alerts. 📡
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Tracking
|
||||
|
||||
Track the last feed check to identify new advisories:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"last_feed_check": "2026-02-02T15:00:00Z",
|
||||
"last_feed_updated": "2026-02-02T12:00:00Z",
|
||||
"known_advisories": ["GA-2026-001", "GA-2026-002"]
|
||||
}
|
||||
```
|
||||
|
||||
Save to: `~/.openclaw/clawsec-feed-state.json`
|
||||
|
||||
### State File Operations
|
||||
|
||||
```bash
|
||||
STATE_FILE="$HOME/.openclaw/clawsec-feed-state.json"
|
||||
|
||||
# Create state file with secure permissions if it doesn't exist
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo '{"schema_version":"1.0","last_feed_check":null,"last_feed_updated":null,"known_advisories":[]}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
|
||||
# Validate state file before reading
|
||||
if ! jq -e '.schema_version' "$STATE_FILE" >/dev/null 2>&1; then
|
||||
echo "Warning: State file corrupted or invalid schema. Creating backup and resetting."
|
||||
cp "$STATE_FILE" "${STATE_FILE}.bak.$(TZ=UTC date +%Y%m%d%H%M%S)"
|
||||
echo '{"schema_version":"1.0","last_feed_check":null,"last_feed_updated":null,"known_advisories":[]}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
|
||||
# Check for major version compatibility
|
||||
SCHEMA_VER=$(jq -r '.schema_version // "0"' "$STATE_FILE")
|
||||
if [[ "${SCHEMA_VER%%.*}" != "1" ]]; then
|
||||
echo "Warning: State file schema version $SCHEMA_VER may not be compatible with this version"
|
||||
fi
|
||||
|
||||
# Update last check time (always use UTC)
|
||||
TEMP_STATE=$(mktemp)
|
||||
if jq --arg t "$(TZ=UTC date +%Y-%m-%dT%H:%M:%SZ)" '.last_feed_check = $t' "$STATE_FILE" > "$TEMP_STATE"; then
|
||||
mv "$TEMP_STATE" "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
else
|
||||
echo "Error: Failed to update state file"
|
||||
rm -f "$TEMP_STATE"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
**Important:** To avoid excessive requests to the feed server, follow these guidelines:
|
||||
|
||||
| Check Type | Recommended Interval | Minimum Interval |
|
||||
|------------|---------------------|------------------|
|
||||
| Heartbeat check | Every 15-30 minutes | 5 minutes |
|
||||
| Full feed refresh | Every 1-4 hours | 30 minutes |
|
||||
| Cross-reference scan | Once per session | 5 minutes |
|
||||
|
||||
```bash
|
||||
# Check if enough time has passed since last check
|
||||
STATE_FILE="$HOME/.openclaw/clawsec-feed-state.json"
|
||||
MIN_INTERVAL_SECONDS=300 # 5 minutes
|
||||
|
||||
LAST_CHECK=$(jq -r '.last_feed_check // "1970-01-01T00:00:00Z"' "$STATE_FILE" 2>/dev/null)
|
||||
LAST_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_CHECK" +%s 2>/dev/null || date -d "$LAST_CHECK" +%s 2>/dev/null || echo 0)
|
||||
NOW_EPOCH=$(TZ=UTC date +%s)
|
||||
|
||||
if [ $((NOW_EPOCH - LAST_EPOCH)) -lt $MIN_INTERVAL_SECONDS ]; then
|
||||
echo "Rate limit: Last check was less than 5 minutes ago. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (Optional)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `CLAWSEC_FEED_URL` | Custom advisory feed URL | Raw GitHub (`main` branch) |
|
||||
| `CLAWSEC_INSTALL_DIR` | Installation directory | `~/.openclaw/skills/clawsec-feed` |
|
||||
|
||||
---
|
||||
|
||||
## Updating ClawSec Feed
|
||||
|
||||
Check for and install newer versions:
|
||||
|
||||
```bash
|
||||
# Check current installed version
|
||||
INSTALL_DIR="${CLAWSEC_INSTALL_DIR:-$HOME/.openclaw/skills/clawsec-feed}"
|
||||
CURRENT_VERSION=$(jq -r '.version' "$INSTALL_DIR/skill.json" 2>/dev/null || echo "unknown")
|
||||
echo "Installed version: $CURRENT_VERSION"
|
||||
|
||||
# Check latest available version
|
||||
LATEST_URL="https://api.github.com/repos/prompt-security/ClawSec/releases"
|
||||
LATEST_VERSION=$(curl -sSL --fail --show-error --retry 3 --retry-delay 1 "$LATEST_URL" 2>/dev/null | \
|
||||
jq -r '[.[] | select(.tag_name | startswith("clawsec-feed-v"))][0].tag_name // empty' | \
|
||||
sed 's/clawsec-feed-v//')
|
||||
|
||||
if [ -z "$LATEST_VERSION" ]; then
|
||||
echo "Warning: Could not determine latest version"
|
||||
else
|
||||
echo "Latest version: $LATEST_VERSION"
|
||||
|
||||
if [ "$CURRENT_VERSION" != "$LATEST_VERSION" ]; then
|
||||
echo "Update available! Run the deployment steps with the new version."
|
||||
else
|
||||
echo "You are running the latest version."
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Initial Download Integrity
|
||||
|
||||
**Bootstrap Trust Problem:** The initial download of this skill cannot be verified by the skill itself. To establish trust:
|
||||
|
||||
1. **Verify the source URL** - Ensure you are downloading from `https://clawsec.prompt.security`
|
||||
2. **Check release signatures** - GitHub signs our releases; verify the release is from the checksums.
|
||||
3. **Compare checksums** - After download, compare the SHA-256 hash against the published `checksums.json`:
|
||||
|
||||
```bash
|
||||
# After downloading SKILL.md, verify its integrity
|
||||
EXPECTED_HASH="<hash-from-checksums.json>"
|
||||
ACTUAL_HASH=$(shasum -a 256 SKILL.md | cut -d' ' -f1)
|
||||
|
||||
if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then
|
||||
echo "ERROR: Skill file integrity check failed!"
|
||||
echo "This file may have been tampered with. Do not proceed."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**Note:** For maximum security, verify checksums.json via a separate trusted channel (e.g., direct from GitHub release page UI, not via curl).
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **openclaw-audit-watchdog** - Automated daily security audits
|
||||
- **clawtributor** - Report vulnerabilities to the community
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See repository for details.
|
||||
|
||||
Built with 📡 by the [Prompt Security](https://prompt.security) team and the agent community.
|
||||
@@ -1,106 +0,0 @@
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"updated": "2026-02-08T06:16:28Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:07.287",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-r8g4-86fx-92mq"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25475"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25157",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:06.577",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-q284-4pvr-m585"
|
||||
],
|
||||
"cvss_score": 7.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157"
|
||||
},
|
||||
{
|
||||
"id": "CLAW-2026-0001",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection",
|
||||
"title": "Data exfiltration attempt via helper-plus skill",
|
||||
"description": "The helper-plus skill was observed sending conversation data to an external server (suspicious-domain.com) on every invocation. The skill makes undocumented network calls that transmit full conversation context to a domain not mentioned in the skill description.",
|
||||
"affected": [
|
||||
"helper-plus@1.0.0",
|
||||
"helper-plus@1.0.1"
|
||||
],
|
||||
"action": "Remove helper-plus immediately. Do not use versions 1.0.0 or 1.0.1. Wait for a verified patched version.",
|
||||
"published": "2026-02-04T09:30:00Z",
|
||||
"references": [],
|
||||
"source": "Community Report",
|
||||
"github_issue_url": "https://github.com/prompt-security/clawsec/issues/1",
|
||||
"reporter": {
|
||||
"agent_name": "SecurityBot",
|
||||
"opener_type": "agent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24763",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/771f23d36b95ec2204cc9a0054045f5d8439ea75",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.1.29",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-mc68-q9jw-2h3v"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24763"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25253",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...",
|
||||
"description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-01T23:15:49.717",
|
||||
"references": [
|
||||
"https://depthfirst.com/post/1-click-rce-to-steal-your-moltbot-data-and-keys",
|
||||
"https://ethiack.com/news/blog/one-click-rce-moltbot",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g8p2-7wf7-98mq"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25253"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "clawsec-feed",
|
||||
"version": "0.0.4",
|
||||
"description": "Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"homepage": "https://clawsec.prompt.security",
|
||||
"keywords": [
|
||||
"security",
|
||||
"advisory",
|
||||
"feed",
|
||||
"agents",
|
||||
"ai",
|
||||
"threat-intel",
|
||||
"monitoring"
|
||||
],
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "Advisory feed skill documentation"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed.json",
|
||||
"required": true,
|
||||
"description": "Community security advisory feed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"openclaw": {
|
||||
"emoji": "📡",
|
||||
"category": "security",
|
||||
"feed_url": "https://api.github.com/repos/prompt-security/ClawSec/releases?skill=clawsec-feed",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"curl",
|
||||
"jq"
|
||||
]
|
||||
},
|
||||
"triggers": [
|
||||
"security advisories",
|
||||
"check advisories",
|
||||
"clawsec",
|
||||
"threat feed",
|
||||
"security alerts",
|
||||
"vulnerability feed",
|
||||
"advisory feed",
|
||||
"security news"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# Exclude local caches and build outputs from ClawHub upload
|
||||
.DS_Store
|
||||
.git/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.venv/
|
||||
.cache/
|
||||
@@ -1,76 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the ClawSec Suite will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.10] - 2026-02-11
|
||||
|
||||
### Security
|
||||
|
||||
#### Transport Security Hardening
|
||||
- **TLS Version Enforcement**: Eliminated support for TLS 1.0 and TLS 1.1, enforcing minimum TLS 1.2 for all HTTPS connections
|
||||
- **Certificate Validation**: Enabled strict certificate validation (`rejectUnauthorized: true`) to prevent MITM attacks
|
||||
- **Domain Allowlist**: Restricted advisory feed connections to approved domains only:
|
||||
- `clawsec.prompt.security` (official ClawSec feed host)
|
||||
- `prompt.security` (parent domain)
|
||||
- `raw.githubusercontent.com` (GitHub raw content)
|
||||
- `github.com` (GitHub releases)
|
||||
- **Strong Cipher Suites**: Configured modern cipher suites (AES-GCM, ChaCha20-Poly1305) for secure connections
|
||||
|
||||
#### Signature Verification & Checksum Validation
|
||||
- **Fixed unverified file publication**: Refactored `deploy-pages.yml` workflow to download release assets to temporary directory before signature verification, ensuring unverified files never reach public directory
|
||||
- **Fixed schema mismatch**: Updated `deploy-pages.yml` to generate `checksums.json` with proper `schema_version` and `algorithm` fields that match parser expectations
|
||||
- **Fixed missing checksums abort**: Updated `loadRemoteFeed` to gracefully skip checksum verification when `checksums.json` is missing (e.g., GitHub raw content), while still enforcing fail-closed signature verification
|
||||
- **Fixed parser strictness**: Enhanced `parseChecksumsManifest` to accept legacy manifest formats through a fallback chain:
|
||||
1. `schema_version` (new standard)
|
||||
2. `version` (skill-release.yml format)
|
||||
3. `generated_at` (old deploy-pages.yml format)
|
||||
4. `"1"` (ultimate fallback)
|
||||
|
||||
### Changed
|
||||
- Advisory feed loader now uses `secureFetch` wrapper with TLS 1.2+ enforcement and domain validation
|
||||
- Checksum verification is now graceful: feeds load successfully from sources without checksums (e.g., GitHub raw) while maintaining fail-closed signature verification
|
||||
- Workflow release mirroring flow changed from `download → verify → skip` to `download to temp → verify → mirror` (fail = delete temp)
|
||||
|
||||
### Fixed
|
||||
- Unverified skill releases no longer published to public directory on signature verification failure
|
||||
- Schema mismatch between generated and expected checksums manifest fields
|
||||
- Feed loading failures when checksums.json missing from upstream sources
|
||||
- Parser rejection of valid legacy manifest formats
|
||||
|
||||
### Security Impact
|
||||
- **Fail-closed security maintained**: All feed signatures still verified; invalid signatures reject feed loading
|
||||
- **No backward compatibility break**: Legacy manifests continue working through fallback chain
|
||||
- **Enhanced transport security**: Connections protected against downgrade attacks and MITM
|
||||
- **Defense in depth**: Multiple layers of verification (domain, TLS, certificate, signature, checksum)
|
||||
|
||||
---
|
||||
|
||||
## Release Notes Template
|
||||
|
||||
When creating a new release, copy this template to the GitHub release notes:
|
||||
|
||||
```markdown
|
||||
## Security Improvements
|
||||
|
||||
### Transport Security
|
||||
✅ TLS 1.2+ enforcement (eliminated TLS 1.0, 1.1)
|
||||
✅ Strict certificate validation
|
||||
✅ Domain allowlist (prompt.security, github.com only)
|
||||
✅ Modern cipher suites (AES-GCM, ChaCha20-Poly1305)
|
||||
|
||||
### Signature & Checksum Verification
|
||||
✅ Unverified files never published (temp directory workflow)
|
||||
✅ Proper schema fields in generated checksums.json
|
||||
✅ Graceful fallback when checksums missing (GitHub raw)
|
||||
✅ Legacy manifest format support (backward compatible)
|
||||
|
||||
### Testing
|
||||
All verification tests passed:
|
||||
- ✅ Unit tests: 14/14 passed
|
||||
- ✅ Parser lenience: 3/3 legacy formats accepted
|
||||
- ✅ Remote loading: Gracefully handles missing checksums
|
||||
- ✅ Workflow security: Temp directory prevents unverified publication
|
||||
```
|
||||
@@ -1,201 +0,0 @@
|
||||
# ClawSec Suite — Heartbeat Check
|
||||
|
||||
Run this periodically (cron/systemd/CI/agent scheduler). It assumes POSIX shell, `curl`, and `jq`.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Check whether `clawsec-suite` has an update available.
|
||||
2. Poll the advisory feed.
|
||||
3. Report new advisories, highlight affected installed skills, and require approval before removal actions.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
SUITE_DIR="$INSTALL_ROOT/clawsec-suite"
|
||||
CHECKSUMS_URL="${CHECKSUMS_URL:-https://clawsec.prompt.security/releases/latest/download/checksums.json}"
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}"
|
||||
STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}"
|
||||
MIN_FEED_INTERVAL_SECONDS="${MIN_FEED_INTERVAL_SECONDS:-300}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — Basic sanity
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
test -d "$SUITE_DIR"
|
||||
test -f "$SUITE_DIR/skill.json"
|
||||
|
||||
echo "=== ClawSec Suite Heartbeat ==="
|
||||
echo "When: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Suite: $SUITE_DIR"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Check suite version updates
|
||||
|
||||
```bash
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
curl -fsSLo "$TMP/checksums.json" "$CHECKSUMS_URL"
|
||||
|
||||
INSTALLED_VER="$(jq -r '.version // ""' "$SUITE_DIR/skill.json" 2>/dev/null || true)"
|
||||
LATEST_VER="$(jq -r '.version // ""' "$TMP/checksums.json" 2>/dev/null || true)"
|
||||
|
||||
echo "Installed suite: ${INSTALLED_VER:-unknown}"
|
||||
echo "Latest suite: ${LATEST_VER:-unknown}"
|
||||
|
||||
if [ -n "$LATEST_VER" ] && [ "$LATEST_VER" != "$INSTALLED_VER" ]; then
|
||||
echo "UPDATE AVAILABLE: clawsec-suite ${INSTALLED_VER:-unknown} -> $LATEST_VER"
|
||||
else
|
||||
echo "Suite appears up to date."
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Initialize advisory state
|
||||
|
||||
```bash
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
|
||||
if ! jq -e '.schema_version and .known_advisories' "$STATE_FILE" >/dev/null 2>&1; then
|
||||
echo "WARNING: Invalid state file, resetting: $STATE_FILE"
|
||||
cp "$STATE_FILE" "${STATE_FILE}.bak.$(date -u +%Y%m%d%H%M%S)" 2>/dev/null || true
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Advisory feed check (embedded clawsec-feed)
|
||||
|
||||
```bash
|
||||
now_epoch="$(date -u +%s)"
|
||||
last_check="$(jq -r '.last_feed_check // "1970-01-01T00:00:00Z"' "$STATE_FILE")"
|
||||
last_epoch="$(date -u -d "$last_check" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$last_check" +%s 2>/dev/null || echo 0)"
|
||||
|
||||
if [ $((now_epoch - last_epoch)) -lt "$MIN_FEED_INTERVAL_SECONDS" ]; then
|
||||
echo "Feed check skipped (rate limit: ${MIN_FEED_INTERVAL_SECONDS}s)."
|
||||
else
|
||||
FEED_TMP="$TMP/feed.json"
|
||||
FEED_SOURCE="$FEED_URL"
|
||||
|
||||
if ! curl -fsSLo "$FEED_TMP" "$FEED_URL"; then
|
||||
if [ -f "$SUITE_DIR/advisories/feed.json" ]; then
|
||||
cp "$SUITE_DIR/advisories/feed.json" "$FEED_TMP"
|
||||
FEED_SOURCE="$SUITE_DIR/advisories/feed.json (local fallback)"
|
||||
echo "WARNING: Remote feed unavailable, using local fallback."
|
||||
else
|
||||
echo "ERROR: Remote feed unavailable and no local fallback feed found."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! jq -e '.version and (.advisories | type == "array")' "$FEED_TMP" >/dev/null 2>&1; then
|
||||
echo "ERROR: Advisory feed has invalid format."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Feed source: $FEED_SOURCE"
|
||||
echo "Feed updated: $(jq -r '.updated // "unknown"' "$FEED_TMP")"
|
||||
|
||||
NEW_IDS_FILE="$TMP/new_ids.txt"
|
||||
jq -r --argfile state "$STATE_FILE" '($state.known_advisories // []) as $known | [.advisories[]?.id | select(. != null and ($known | index(.) | not))] | .[]?' "$FEED_TMP" > "$NEW_IDS_FILE"
|
||||
|
||||
if [ -s "$NEW_IDS_FILE" ]; then
|
||||
echo "New advisories:"
|
||||
while IFS= read -r id; do
|
||||
[ -z "$id" ] && continue
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$FEED_TMP"
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | " Action: \(.action // "Review advisory details")"' "$FEED_TMP"
|
||||
done < "$NEW_IDS_FILE"
|
||||
else
|
||||
echo "FEED_OK - no new advisories"
|
||||
fi
|
||||
|
||||
echo "Affected installed skills (if any):"
|
||||
found_affected=0
|
||||
removal_recommended=0
|
||||
for skill_path in "$INSTALL_ROOT"/*; do
|
||||
[ -d "$skill_path" ] || continue
|
||||
skill_name="$(basename "$skill_path")"
|
||||
|
||||
skill_hits="$(jq -r --arg skill_prefix "${skill_name}@" '
|
||||
[.advisories[]
|
||||
| select(any(.affected[]?; startswith($skill_prefix)))
|
||||
| "- [\(.severity | ascii_upcase)] \(.id): \(.title)\n Action: \(.action // "Review advisory details")"
|
||||
] | .[]?
|
||||
' "$FEED_TMP")"
|
||||
|
||||
if [ -n "$skill_hits" ]; then
|
||||
found_affected=1
|
||||
echo "- $skill_name is referenced by advisory feed entries"
|
||||
printf "%s\n" "$skill_hits"
|
||||
|
||||
if jq -e --arg skill_prefix "${skill_name}@" '
|
||||
any(
|
||||
.advisories[];
|
||||
any(.affected[]?; startswith($skill_prefix))
|
||||
and (
|
||||
((.type // "" | ascii_downcase) == "malicious_skill")
|
||||
or ((.title // "" | ascii_downcase | test("malicious|exfiltrat|backdoor|trojan|stealer")))
|
||||
or ((.description // "" | ascii_downcase | test("malicious|exfiltrat|backdoor|trojan|stealer")))
|
||||
or ((.action // "" | ascii_downcase | test("remove|uninstall|disable|do not use|quarantine")))
|
||||
)
|
||||
)
|
||||
' "$FEED_TMP" >/dev/null 2>&1; then
|
||||
removal_recommended=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found_affected" -eq 0 ]; then
|
||||
echo "- none"
|
||||
fi
|
||||
|
||||
if [ "$removal_recommended" -eq 1 ]; then
|
||||
echo "Approval required: ask the user for explicit approval before removing any skill."
|
||||
echo "Double-confirmation policy: install request is first intent; require a second explicit confirmation with advisory context."
|
||||
fi
|
||||
|
||||
# Persist state
|
||||
current_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
state_tmp="$TMP/state.json"
|
||||
|
||||
jq --arg t "$current_utc" --arg updated "$(jq -r '.updated // ""' "$FEED_TMP")" --argfile feed "$FEED_TMP" '
|
||||
.last_feed_check = $t
|
||||
| .last_feed_updated = (if $updated == "" then .last_feed_updated else $updated end)
|
||||
| .known_advisories = ((.known_advisories // []) + [$feed.advisories[]?.id] | map(select(. != null)) | unique)
|
||||
' "$STATE_FILE" > "$state_tmp"
|
||||
|
||||
mv "$state_tmp" "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Summary
|
||||
|
||||
Heartbeat output should include:
|
||||
- suite version status,
|
||||
- advisory feed status,
|
||||
- new advisory list (if any),
|
||||
- installed skills that appear in advisory `affected` lists,
|
||||
- and a double-confirmation reminder before risky install/remove actions.
|
||||
|
||||
If your runtime sends alerts, treat `critical` and `high` advisories affecting installed skills as immediate notifications.
|
||||
@@ -1,292 +0,0 @@
|
||||
---
|
||||
name: clawsec-suite
|
||||
version: 0.0.10
|
||||
description: ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "📦"
|
||||
requires:
|
||||
bins: [curl, jq, shasum, openssl]
|
||||
---
|
||||
|
||||
# ClawSec Suite
|
||||
|
||||
This means `clawsec-suite` can:
|
||||
- monitor the ClawSec advisory feed,
|
||||
- track which advisories are new since last check,
|
||||
- cross-reference advisories against locally installed skills,
|
||||
- recommend removal for malicious-skill advisories and require explicit user approval first,
|
||||
- and still act as the setup/management entrypoint for other ClawSec protections.
|
||||
|
||||
## Included vs Optional Protections
|
||||
|
||||
### Built into clawsec-suite
|
||||
- Embedded feed seed file: `advisories/feed.json`
|
||||
- Portable heartbeat workflow in `HEARTBEAT.md`
|
||||
- Advisory polling + state tracking + affected-skill checks
|
||||
- OpenClaw advisory guardian hook package: `hooks/clawsec-advisory-guardian/`
|
||||
- Setup scripts for hook and optional cron scheduling: `scripts/`
|
||||
- Guarded installer: `scripts/guarded_skill_install.mjs`
|
||||
|
||||
### installed separately
|
||||
- `openclaw-audit-watchdog`
|
||||
- `soul-guardian`
|
||||
- `clawtributor` (explicit opt-in)
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A: Via clawhub (recommended)
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install clawsec-suite
|
||||
```
|
||||
|
||||
### Option B: Manual download with signature + checksum verification
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${SKILL_VERSION:?Set SKILL_VERSION (e.g. 0.0.8)}"
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
DEST="$INSTALL_ROOT/clawsec-suite"
|
||||
BASE="https://github.com/prompt-security/clawsec/releases/download/clawsec-suite-v${VERSION}"
|
||||
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
DOWNLOAD_DIR="$TEMP_DIR/downloads"
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
mkdir -p "$DOWNLOAD_DIR"
|
||||
|
||||
# Pinned release-signing public key (verify fingerprint out-of-band on first use)
|
||||
# Fingerprint (SHA-256 of SPKI DER): 35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854
|
||||
RELEASE_PUBKEY_SHA256="35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854"
|
||||
cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM'
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAtaRGONGp0Syl9EBS17hEYgGTwUtfZgigklS6vAe5MlQ=
|
||||
-----END PUBLIC KEY-----
|
||||
PEM
|
||||
|
||||
ACTUAL_KEY_SHA256="$(openssl pkey -pubin -in "$TEMP_DIR/release-signing-public.pem" -outform DER | shasum -a 256 | awk '{print $1}')"
|
||||
if [ "$ACTUAL_KEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
|
||||
echo "ERROR: Release public key fingerprint mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1) Download checksums manifest + detached signature
|
||||
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
|
||||
curl -fsSL "$BASE/checksums.json.sig" -o "$TEMP_DIR/checksums.json.sig"
|
||||
|
||||
# 2) Verify checksums manifest signature before trusting any file URLs or hashes
|
||||
openssl base64 -d -A -in "$TEMP_DIR/checksums.json.sig" -out "$TEMP_DIR/checksums.json.sig.bin"
|
||||
if ! openssl pkeyutl -verify \
|
||||
-pubin \
|
||||
-inkey "$TEMP_DIR/release-signing-public.pem" \
|
||||
-sigfile "$TEMP_DIR/checksums.json.sig.bin" \
|
||||
-rawin \
|
||||
-in "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: checksums.json signature verification failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: Invalid checksums.json format" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checksums manifest signature verified."
|
||||
|
||||
# 3) Download every file listed in checksums and verify immediately
|
||||
DOWNLOAD_FAILED=0
|
||||
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
|
||||
FILE_URL="$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")"
|
||||
EXPECTED="$(jq -r --arg f "$file" '.files[$f].sha256' "$TEMP_DIR/checksums.json")"
|
||||
|
||||
if ! curl -fsSL "$FILE_URL" -o "$DOWNLOAD_DIR/$file"; then
|
||||
echo "ERROR: Download failed for $file" >&2
|
||||
DOWNLOAD_FAILED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
ACTUAL="$(shasum -a 256 "$DOWNLOAD_DIR/$file" | awk '{print $1}')"
|
||||
else
|
||||
ACTUAL="$(sha256sum "$DOWNLOAD_DIR/$file" | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "ERROR: Checksum mismatch for $file" >&2
|
||||
DOWNLOAD_FAILED=1
|
||||
else
|
||||
echo "Verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DOWNLOAD_FAILED" -eq 1 ]; then
|
||||
echo "ERROR: One or more files failed verification" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4) Install files using paths from checksums.json
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")"
|
||||
SRC_PATH="$DOWNLOAD_DIR/$file"
|
||||
DST_PATH="$DEST/$REL_PATH"
|
||||
|
||||
mkdir -p "$(dirname "$DST_PATH")"
|
||||
cp "$SRC_PATH" "$DST_PATH"
|
||||
done < <(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json")
|
||||
|
||||
chmod 600 "$DEST/skill.json"
|
||||
find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||
|
||||
echo "Installed clawsec-suite v${VERSION} to: $DEST"
|
||||
echo "Next step (OpenClaw): node \"\$DEST/scripts/setup_advisory_hook.mjs\""
|
||||
```
|
||||
|
||||
## OpenClaw Automation (Hook + Optional Cron)
|
||||
|
||||
After installing the suite, enable the advisory guardian hook:
|
||||
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/setup_advisory_hook.mjs"
|
||||
```
|
||||
|
||||
Optional: create/update a periodic cron nudge (default every `6h`) that triggers a main-session advisory scan:
|
||||
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/setup_advisory_cron.mjs"
|
||||
```
|
||||
|
||||
What this adds:
|
||||
- scan on `agent:bootstrap` and `/new` (`command:new`),
|
||||
- compare advisory `affected` entries against installed skills,
|
||||
- notify when new matches appear,
|
||||
- and ask for explicit user approval before any removal flow.
|
||||
|
||||
Restart the OpenClaw gateway after enabling the hook. Then run `/new` once to force an immediate scan in the next session context.
|
||||
|
||||
## Guarded Skill Install Flow (Double Confirmation)
|
||||
|
||||
When the user asks to install a skill, treat that as the first request and run a guarded install check:
|
||||
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --version 1.0.1
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- If no advisory match is found, install proceeds.
|
||||
- If `--version` is omitted, matching is conservative: any advisory that references the skill name is treated as a match.
|
||||
- If advisory match is found, the script prints advisory context and exits with code `42`.
|
||||
- Then require an explicit second confirmation from the user and rerun with `--confirm-advisory`:
|
||||
|
||||
```bash
|
||||
node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --version 1.0.1 --confirm-advisory
|
||||
```
|
||||
|
||||
This enforces:
|
||||
1. First confirmation: user asked to install.
|
||||
2. Second confirmation: user explicitly approves install after seeing advisory details.
|
||||
|
||||
## Embedded Advisory Feed Behavior
|
||||
|
||||
The embedded feed logic uses these defaults:
|
||||
|
||||
- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
- Remote feed signature URL: `${CLAWSEC_FEED_URL}.sig` (override with `CLAWSEC_FEED_SIG_URL`)
|
||||
- Remote checksums manifest URL: sibling `checksums.json` (override with `CLAWSEC_FEED_CHECKSUMS_URL`)
|
||||
- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json`
|
||||
- Local feed signature: `${CLAWSEC_LOCAL_FEED}.sig` (override with `CLAWSEC_LOCAL_FEED_SIG`)
|
||||
- Local checksums manifest: `~/.openclaw/skills/clawsec-suite/advisories/checksums.json`
|
||||
- Pinned feed signing key: `~/.openclaw/skills/clawsec-suite/advisories/feed-signing-public.pem` (override with `CLAWSEC_FEED_PUBLIC_KEY`)
|
||||
- State file: `~/.openclaw/clawsec-suite-feed-state.json`
|
||||
- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`)
|
||||
|
||||
**Fail-closed verification:** Both signature and checksum manifest verification are required by default. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream.
|
||||
|
||||
### Quick feed check
|
||||
|
||||
```bash
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}"
|
||||
STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
if ! curl -fsSLo "$TMP/feed.json" "$FEED_URL"; then
|
||||
echo "ERROR: Failed to fetch advisory feed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.version and (.advisories | type == "array")' "$TMP/feed.json" >/dev/null; then
|
||||
echo "ERROR: Invalid advisory feed format"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
|
||||
NEW_IDS_FILE="$TMP/new_ids.txt"
|
||||
jq -r --argfile state "$STATE_FILE" '($state.known_advisories // []) as $known | [.advisories[]?.id | select(. != null and ($known | index(.) | not))] | .[]?' "$TMP/feed.json" > "$NEW_IDS_FILE"
|
||||
|
||||
if [ -s "$NEW_IDS_FILE" ]; then
|
||||
echo "New advisories detected:"
|
||||
while IFS= read -r id; do
|
||||
[ -z "$id" ] && continue
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$TMP/feed.json"
|
||||
done < "$NEW_IDS_FILE"
|
||||
else
|
||||
echo "FEED_OK - no new advisories"
|
||||
fi
|
||||
```
|
||||
|
||||
## Heartbeat Integration
|
||||
|
||||
Use the suite heartbeat script as the single periodic security check entrypoint:
|
||||
|
||||
- `skills/clawsec-suite/HEARTBEAT.md`
|
||||
|
||||
It handles:
|
||||
- suite update checks,
|
||||
- feed polling,
|
||||
- new-advisory detection,
|
||||
- affected-skill cross-referencing,
|
||||
- approval-gated response guidance for malicious/removal advisories,
|
||||
- and persistent state updates.
|
||||
|
||||
## Approval-Gated Response Contract
|
||||
|
||||
If an advisory indicates a malicious or removal-recommended skill and that skill is installed:
|
||||
|
||||
1. Notify the user immediately with advisory details and severity.
|
||||
2. Recommend removing or disabling the affected skill.
|
||||
3. Treat the original install request as first intent only.
|
||||
4. Ask for explicit second confirmation before deletion/disable action (or before proceeding with risky install).
|
||||
5. Only proceed after that second confirmation.
|
||||
|
||||
The suite hook and heartbeat guidance are intentionally non-destructive by default.
|
||||
|
||||
## Optional Skill Installation
|
||||
|
||||
Install additional protections as needed:
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install openclaw-audit-watchdog
|
||||
npx clawhub@latest install soul-guardian
|
||||
# opt-in only:
|
||||
npx clawhub@latest install clawtributor
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Always verify `checksums.json` signature before trusting its file URLs/hashes, then verify each file checksum.
|
||||
- Verify advisory feed detached signatures; do not enable `CLAWSEC_ALLOW_UNSIGNED_FEED` outside temporary migration windows.
|
||||
- Keep advisory polling rate-limited (at least 5 minutes between checks).
|
||||
- Treat `critical` and `high` advisories affecting installed skills as immediate action items.
|
||||
- If you migrate off standalone `clawsec-feed`, keep one canonical state file to avoid duplicate notifications.
|
||||
- Pin and verify public key fingerprints out-of-band before first use.
|
||||
@@ -1,3 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,106 +0,0 @@
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"updated": "2026-02-08T06:16:28Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:07.287",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-r8g4-86fx-92mq"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25475"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25157",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:06.577",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-q284-4pvr-m585"
|
||||
],
|
||||
"cvss_score": 7.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157"
|
||||
},
|
||||
{
|
||||
"id": "CLAW-2026-0001",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection",
|
||||
"title": "Data exfiltration attempt via helper-plus skill",
|
||||
"description": "The helper-plus skill was observed sending conversation data to an external server (suspicious-domain.com) on every invocation. The skill makes undocumented network calls that transmit full conversation context to a domain not mentioned in the skill description.",
|
||||
"affected": [
|
||||
"helper-plus@1.0.0",
|
||||
"helper-plus@1.0.1"
|
||||
],
|
||||
"action": "Remove helper-plus immediately. Do not use versions 1.0.0 or 1.0.1. Wait for a verified patched version.",
|
||||
"published": "2026-02-04T09:30:00Z",
|
||||
"references": [],
|
||||
"source": "Community Report",
|
||||
"github_issue_url": "https://github.com/prompt-security/clawsec/issues/1",
|
||||
"reporter": {
|
||||
"agent_name": "SecurityBot",
|
||||
"opener_type": "agent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24763",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/771f23d36b95ec2204cc9a0054045f5d8439ea75",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.1.29",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-mc68-q9jw-2h3v"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24763"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25253",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...",
|
||||
"description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-01T23:15:49.717",
|
||||
"references": [
|
||||
"https://depthfirst.com/post/1-click-rce-to-steal-your-moltbot-data-and-keys",
|
||||
"https://ethiack.com/news/blog/one-click-rce-moltbot",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g8p2-7wf7-98mq"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25253"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: clawsec-advisory-guardian
|
||||
description: Detect advisory matches for installed skills and require explicit user approval before any removal action.
|
||||
metadata: { "openclaw": { "events": ["agent:bootstrap", "command:new"] } }
|
||||
---
|
||||
|
||||
# ClawSec Advisory Guardian Hook
|
||||
|
||||
This hook checks the ClawSec advisory feed against locally installed skills on:
|
||||
|
||||
- `agent:bootstrap`
|
||||
- `command:new`
|
||||
|
||||
When it detects an advisory affecting an installed skill, it posts an alert message.
|
||||
If the advisory looks malicious or removal-oriented, it explicitly recommends removal
|
||||
and asks for user approval first.
|
||||
|
||||
## Safety Contract
|
||||
|
||||
- The hook does not delete or modify skills.
|
||||
- It only reports findings and requests explicit approval before removal.
|
||||
- Alerts are deduplicated using `~/.openclaw/clawsec-suite-feed-state.json`.
|
||||
|
||||
## Optional Environment Variables
|
||||
|
||||
- `CLAWSEC_FEED_URL`: override remote feed URL.
|
||||
- `CLAWSEC_FEED_SIG_URL`: override detached remote feed signature URL (default `${CLAWSEC_FEED_URL}.sig`).
|
||||
- `CLAWSEC_FEED_CHECKSUMS_URL`: override remote checksum manifest URL (default sibling `checksums.json`).
|
||||
- `CLAWSEC_FEED_CHECKSUMS_SIG_URL`: override detached remote checksum manifest signature URL.
|
||||
- `CLAWSEC_FEED_PUBLIC_KEY`: path to pinned feed-signing public key PEM.
|
||||
- `CLAWSEC_LOCAL_FEED`: override local fallback feed file.
|
||||
- `CLAWSEC_LOCAL_FEED_SIG`: override local detached feed signature path.
|
||||
- `CLAWSEC_LOCAL_FEED_CHECKSUMS`: override local checksum manifest path.
|
||||
- `CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG`: override local checksum manifest signature path.
|
||||
- `CLAWSEC_VERIFY_CHECKSUM_MANIFEST`: set to `0` only for emergency troubleshooting (default verifies checksums).
|
||||
- `CLAWSEC_ALLOW_UNSIGNED_FEED`: set to `1` only for temporary migration compatibility; bypasses signature/checksum verification.
|
||||
- `CLAWSEC_SUITE_STATE_FILE`: override state file path.
|
||||
- `CLAWSEC_INSTALL_ROOT`: override installed skills root.
|
||||
- `CLAWSEC_SUITE_DIR`: override clawsec-suite install path.
|
||||
- `CLAWSEC_HOOK_INTERVAL_SECONDS`: minimum interval between hook scans (default `300`).
|
||||
@@ -1,198 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { uniqueStrings } from "./lib/utils.mjs";
|
||||
import { defaultChecksumsUrl, loadLocalFeed, loadRemoteFeed } from "./lib/feed.mjs";
|
||||
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
|
||||
import { loadState, persistState } from "./lib/state.ts";
|
||||
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
||||
let unsignedModeWarningShown = false;
|
||||
|
||||
function expandHome(inputPath: string): string {
|
||||
if (!inputPath) return inputPath;
|
||||
if (inputPath === "~") return os.homedir();
|
||||
if (inputPath.startsWith("~/")) return path.join(os.homedir(), inputPath.slice(2));
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toEventName(event: HookEvent): string {
|
||||
const eventType = String(event.type ?? "").trim();
|
||||
const action = String(event.action ?? "").trim();
|
||||
if (!eventType || !action) return "";
|
||||
return `${eventType}:${action}`;
|
||||
}
|
||||
|
||||
function shouldHandleEvent(event: HookEvent): boolean {
|
||||
const eventName = toEventName(event);
|
||||
return eventName === "agent:bootstrap" || eventName === "command:new";
|
||||
}
|
||||
|
||||
function epochMs(isoTimestamp: string | null): number {
|
||||
if (!isoTimestamp) return 0;
|
||||
const parsed = Date.parse(isoTimestamp);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function scannedRecently(lastScan: string | null, minIntervalSeconds: number): boolean {
|
||||
const sinceMs = Date.now() - epochMs(lastScan);
|
||||
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
|
||||
}
|
||||
|
||||
async function loadFeed(options: {
|
||||
feedUrl: string;
|
||||
feedSignatureUrl: string;
|
||||
feedChecksumsUrl: string;
|
||||
feedChecksumsSignatureUrl: string;
|
||||
localFeedPath: string;
|
||||
localFeedSignaturePath: string;
|
||||
localFeedChecksumsPath: string;
|
||||
localFeedChecksumsSignaturePath: string;
|
||||
feedPublicKeyPath: string;
|
||||
allowUnsigned: boolean;
|
||||
verifyChecksumManifest: boolean;
|
||||
}): Promise<FeedPayload> {
|
||||
const publicKeyPem = options.allowUnsigned ? "" : await fs.readFile(options.feedPublicKeyPath, "utf8");
|
||||
|
||||
const remoteFeed = await loadRemoteFeed(options.feedUrl, {
|
||||
signatureUrl: options.feedSignatureUrl,
|
||||
checksumsUrl: options.feedChecksumsUrl,
|
||||
checksumsSignatureUrl: options.feedChecksumsSignatureUrl,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned: options.allowUnsigned,
|
||||
verifyChecksumManifest: options.verifyChecksumManifest,
|
||||
});
|
||||
if (remoteFeed) return remoteFeed;
|
||||
|
||||
return await loadLocalFeed(options.localFeedPath, {
|
||||
signaturePath: options.localFeedSignaturePath,
|
||||
checksumsPath: options.localFeedChecksumsPath,
|
||||
checksumsSignaturePath: options.localFeedChecksumsSignaturePath,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned: options.allowUnsigned,
|
||||
verifyChecksumManifest: options.verifyChecksumManifest,
|
||||
checksumPublicKeyEntry: path.basename(options.feedPublicKeyPath),
|
||||
});
|
||||
}
|
||||
|
||||
const handler = async (event: HookEvent): Promise<void> => {
|
||||
if (!shouldHandleEvent(event)) return;
|
||||
|
||||
const installRoot = expandHome(
|
||||
process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT || path.join(os.homedir(), ".openclaw", "skills"),
|
||||
);
|
||||
const suiteDir = expandHome(process.env.CLAWSEC_SUITE_DIR || path.join(installRoot, "clawsec-suite"));
|
||||
const localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json"));
|
||||
const localFeedSignaturePath = expandHome(
|
||||
process.env.CLAWSEC_LOCAL_FEED_SIG || `${localFeedPath}.sig`,
|
||||
);
|
||||
const localFeedChecksumsPath = expandHome(
|
||||
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || path.join(path.dirname(localFeedPath), "checksums.json"),
|
||||
);
|
||||
const localFeedChecksumsSignaturePath = expandHome(
|
||||
process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || `${localFeedChecksumsPath}.sig`,
|
||||
);
|
||||
const feedPublicKeyPath = expandHome(
|
||||
process.env.CLAWSEC_FEED_PUBLIC_KEY || path.join(suiteDir, "advisories", "feed-signing-public.pem"),
|
||||
);
|
||||
const stateFile = expandHome(
|
||||
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
|
||||
);
|
||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||
const feedChecksumsSignatureUrl =
|
||||
process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||
const scanIntervalSeconds = parsePositiveInteger(
|
||||
process.env.CLAWSEC_HOOK_INTERVAL_SECONDS,
|
||||
DEFAULT_SCAN_INTERVAL_SECONDS,
|
||||
);
|
||||
|
||||
if (allowUnsigned && !unsignedModeWarningShown) {
|
||||
unsignedModeWarningShown = true;
|
||||
console.warn(
|
||||
"[clawsec-advisory-guardian] CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. " +
|
||||
"This bypass is temporary migration compatibility and should be removed as soon as signed feed artifacts are available.",
|
||||
);
|
||||
}
|
||||
|
||||
const forceScan = toEventName(event) === "command:new";
|
||||
const state = await loadState(stateFile);
|
||||
if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let feed: FeedPayload;
|
||||
try {
|
||||
feed = await loadFeed({
|
||||
feedUrl,
|
||||
feedSignatureUrl,
|
||||
feedChecksumsUrl,
|
||||
feedChecksumsSignatureUrl,
|
||||
localFeedPath,
|
||||
localFeedSignaturePath,
|
||||
localFeedChecksumsPath,
|
||||
localFeedChecksumsSignaturePath,
|
||||
feedPublicKeyPath,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
state.last_hook_scan = nowIso;
|
||||
state.last_feed_check = nowIso;
|
||||
|
||||
if (typeof feed.updated === "string" && feed.updated.trim()) {
|
||||
state.last_feed_updated = feed.updated;
|
||||
}
|
||||
|
||||
const advisoryIds = feed.advisories
|
||||
.map((advisory) => advisory.id)
|
||||
.filter((id): id is string => typeof id === "string" && id.trim() !== "");
|
||||
state.known_advisories = uniqueStrings([...state.known_advisories, ...advisoryIds]);
|
||||
|
||||
const installedSkills = await discoverInstalledSkills(installRoot);
|
||||
const matches = findMatches(feed, installedSkills);
|
||||
|
||||
if (matches.length === 0) {
|
||||
await persistState(stateFile, state);
|
||||
return;
|
||||
}
|
||||
|
||||
const unseenMatches: AdvisoryMatch[] = [];
|
||||
for (const match of matches) {
|
||||
const key = matchKey(match);
|
||||
if (state.notified_matches[key]) {
|
||||
continue;
|
||||
}
|
||||
unseenMatches.push(match);
|
||||
state.notified_matches[key] = nowIso;
|
||||
}
|
||||
|
||||
if (unseenMatches.length > 0 && Array.isArray(event.messages)) {
|
||||
event.messages.push(buildAlertMessage(unseenMatches, installRoot));
|
||||
}
|
||||
|
||||
await persistState(stateFile, state);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -1,511 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import https from "node:https";
|
||||
import path from "node:path";
|
||||
import { isObject } from "./utils.mjs";
|
||||
|
||||
/**
|
||||
* Allowed domains for feed/signature fetching.
|
||||
* Only connections to these domains are permitted for security.
|
||||
*/
|
||||
const ALLOWED_DOMAINS = [
|
||||
"clawsec.prompt.security",
|
||||
"prompt.security",
|
||||
"raw.githubusercontent.com",
|
||||
"github.com",
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom error class for security policy violations.
|
||||
* These errors should always propagate and never be silently caught.
|
||||
*/
|
||||
class SecurityPolicyError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "SecurityPolicyError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a secure HTTPS agent with TLS 1.2+ enforcement and certificate validation.
|
||||
* @returns {https.Agent}
|
||||
*/
|
||||
function createSecureAgent() {
|
||||
return new https.Agent({
|
||||
// Enforce minimum TLS 1.2 (eliminate TLS 1.0, 1.1)
|
||||
minVersion: "TLSv1.2",
|
||||
// Ensure certificate validation is enabled (reject unauthorized certificates)
|
||||
rejectUnauthorized: true,
|
||||
// Use strong cipher suites
|
||||
ciphers: "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL is from an allowed domain.
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAllowedDomain(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow HTTPS protocol
|
||||
if (parsed.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Check if hostname matches any allowed domain
|
||||
return ALLOWED_DOMAINS.some(
|
||||
(allowed) =>
|
||||
hostname === allowed || hostname.endsWith(`.${allowed}`)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure wrapper around fetch with TLS enforcement and domain validation.
|
||||
* @param {string} url
|
||||
* @param {RequestInit} [options]
|
||||
* @returns {Promise<Response>}
|
||||
* @throws {SecurityPolicyError} If URL is not from an allowed domain
|
||||
*/
|
||||
async function secureFetch(url, options = {}) {
|
||||
// Validate domain before making request
|
||||
if (!isAllowedDomain(url)) {
|
||||
throw new SecurityPolicyError(
|
||||
`Security policy violation: URL domain not allowed. ` +
|
||||
`Only connections to ${ALLOWED_DOMAINS.join(", ")} are permitted. ` +
|
||||
`Blocked: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
// Use secure HTTPS agent with TLS 1.2+ enforcement
|
||||
const agent = createSecureAgent();
|
||||
|
||||
return globalThis.fetch(url, {
|
||||
...options,
|
||||
// Attach secure agent for Node.js fetch
|
||||
// @ts-ignore - agent is supported in Node.js fetch
|
||||
agent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawSpecifier
|
||||
* @returns {{ name: string; versionSpec: string } | null}
|
||||
*/
|
||||
export function parseAffectedSpecifier(rawSpecifier) {
|
||||
const specifier = String(rawSpecifier ?? "").trim();
|
||||
if (!specifier) return null;
|
||||
|
||||
const atIndex = specifier.lastIndexOf("@");
|
||||
if (atIndex <= 0) {
|
||||
return { name: specifier, versionSpec: "*" };
|
||||
}
|
||||
|
||||
return {
|
||||
name: specifier.slice(0, atIndex),
|
||||
versionSpec: specifier.slice(atIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {raw is import("./types.ts").FeedPayload}
|
||||
*/
|
||||
export function isValidFeedPayload(raw) {
|
||||
if (!isObject(raw)) return false;
|
||||
if (typeof raw.version !== "string" || !raw.version.trim()) return false;
|
||||
if (!Array.isArray(raw.advisories)) return false;
|
||||
|
||||
for (const advisory of raw.advisories) {
|
||||
if (!isObject(advisory)) return false;
|
||||
if (typeof advisory.id !== "string" || !advisory.id.trim()) return false;
|
||||
if (typeof advisory.severity !== "string" || !advisory.severity.trim()) return false;
|
||||
if (!Array.isArray(advisory.affected)) return false;
|
||||
if (!advisory.affected.every((entry) => typeof entry === "string" && entry.trim())) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signatureRaw
|
||||
* @returns {Buffer | null}
|
||||
*/
|
||||
function decodeSignature(signatureRaw) {
|
||||
const trimmed = String(signatureRaw ?? "").trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let encoded = trimmed;
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (isObject(parsed) && typeof parsed.signature === "string") {
|
||||
encoded = parsed.signature;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = encoded.replace(/\s+/g, "");
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
return Buffer.from(normalized, "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} payloadRaw
|
||||
* @param {string} signatureRaw
|
||||
* @param {string} publicKeyPem
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
|
||||
const signature = decodeSignature(signatureRaw);
|
||||
if (!signature) return false;
|
||||
|
||||
const keyPem = String(publicKeyPem ?? "").trim();
|
||||
if (!keyPem) return false;
|
||||
|
||||
try {
|
||||
const publicKey = crypto.createPublicKey(keyPem);
|
||||
return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | Buffer} content
|
||||
* @returns {string}
|
||||
*/
|
||||
function sha256Hex(content) {
|
||||
return crypto.createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function extractSha256Value(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
if (isObject(value) && typeof value.sha256 === "string") {
|
||||
const normalized = value.sha256.trim().toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} manifestRaw
|
||||
* @returns {{ schemaVersion: string; algorithm: string; files: Record<string, string> }}
|
||||
*/
|
||||
function parseChecksumsManifest(manifestRaw) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(manifestRaw);
|
||||
} catch {
|
||||
throw new Error("Checksum manifest is not valid JSON");
|
||||
}
|
||||
|
||||
if (!isObject(parsed)) {
|
||||
throw new Error("Checksum manifest must be an object");
|
||||
}
|
||||
|
||||
const algorithmRaw = typeof parsed.algorithm === "string" ? parsed.algorithm.trim().toLowerCase() : "sha256";
|
||||
if (algorithmRaw !== "sha256") {
|
||||
throw new Error(`Unsupported checksum manifest algorithm: ${algorithmRaw || "(empty)"}`);
|
||||
}
|
||||
|
||||
// Support legacy manifest formats:
|
||||
// - New standard: schema_version field
|
||||
// - skill-release.yml: version field (e.g., "0.0.1")
|
||||
// - deploy-pages.yml (pre-fix): generated_at field (e.g., "2026-02-08T...")
|
||||
// - Ultimate fallback: "1"
|
||||
const schemaVersion = (
|
||||
typeof parsed.schema_version === "string" ? parsed.schema_version.trim() :
|
||||
typeof parsed.version === "string" ? parsed.version.trim() :
|
||||
typeof parsed.generated_at === "string" ? parsed.generated_at.trim() :
|
||||
"1"
|
||||
);
|
||||
|
||||
if (!schemaVersion) {
|
||||
throw new Error("Checksum manifest missing schema_version");
|
||||
}
|
||||
|
||||
if (!isObject(parsed.files)) {
|
||||
throw new Error("Checksum manifest missing files object");
|
||||
}
|
||||
|
||||
const files = /** @type {Record<string, string>} */ ({});
|
||||
for (const [key, value] of Object.entries(parsed.files)) {
|
||||
if (!String(key).trim()) continue;
|
||||
const digest = extractSha256Value(value);
|
||||
if (!digest) {
|
||||
throw new Error(`Invalid checksum digest entry for ${key}`);
|
||||
}
|
||||
files[key] = digest;
|
||||
}
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
throw new Error("Checksum manifest has no usable file digests");
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion,
|
||||
algorithm: algorithmRaw,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ files: Record<string, string> }} manifest
|
||||
* @param {Record<string, string | Buffer>} expectedEntries
|
||||
*/
|
||||
function verifyChecksums(manifest, expectedEntries) {
|
||||
for (const [entryName, entryContent] of Object.entries(expectedEntries)) {
|
||||
if (!entryName) continue;
|
||||
|
||||
const expectedDigest = manifest.files[entryName];
|
||||
if (!expectedDigest) {
|
||||
throw new Error(`Checksum manifest missing required entry: ${entryName}`);
|
||||
}
|
||||
|
||||
const actualDigest = sha256Hex(entryContent);
|
||||
if (actualDigest !== expectedDigest) {
|
||||
throw new Error(`Checksum mismatch for ${entryName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedUrl
|
||||
* @returns {string}
|
||||
*/
|
||||
export function defaultChecksumsUrl(feedUrl) {
|
||||
try {
|
||||
return new URL("checksums.json", feedUrl).toString();
|
||||
} catch {
|
||||
const fallbackBase = String(feedUrl ?? "").replace(/\/?[^/]*$/, "");
|
||||
return `${fallbackBase}/checksums.json`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts the basename from a URL or file path.
|
||||
* @param {string} urlOrPath
|
||||
* @param {string} fallback
|
||||
* @returns {string}
|
||||
*/
|
||||
function safeBasename(urlOrPath, fallback) {
|
||||
try {
|
||||
// Try parsing as URL first
|
||||
const parsed = new URL(urlOrPath);
|
||||
const pathname = parsed.pathname;
|
||||
const lastSlash = pathname.lastIndexOf("/");
|
||||
if (lastSlash >= 0 && lastSlash < pathname.length - 1) {
|
||||
return pathname.slice(lastSlash + 1);
|
||||
}
|
||||
} catch {
|
||||
// Not a URL, try as path
|
||||
const normalized = String(urlOrPath ?? "").trim();
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
if (lastSlash >= 0 && lastSlash < normalized.length - 1) {
|
||||
return normalized.slice(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Function} fetchFn
|
||||
* @param {string} targetUrl
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function fetchText(fetchFn, targetUrl) {
|
||||
const controller = new globalThis.AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetchFn(targetUrl, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
// Re-throw security policy violations - these should never be silently caught
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
throw error;
|
||||
}
|
||||
// Network errors, timeouts, etc. return null (graceful degradation)
|
||||
return null;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedPath
|
||||
* @param {{
|
||||
* signaturePath?: string;
|
||||
* checksumsPath?: string;
|
||||
* checksumsSignaturePath?: string;
|
||||
* publicKeyPem?: string;
|
||||
* checksumsPublicKeyPem?: string;
|
||||
* allowUnsigned?: boolean;
|
||||
* verifyChecksumManifest?: boolean;
|
||||
* checksumFeedEntry?: string;
|
||||
* checksumSignatureEntry?: string;
|
||||
* checksumPublicKeyEntry?: string;
|
||||
* }} [options]
|
||||
* @returns {Promise<import("./types.ts").FeedPayload>}
|
||||
*/
|
||||
export async function loadLocalFeed(feedPath, options = {}) {
|
||||
const signaturePath = options.signaturePath ?? `${feedPath}.sig`;
|
||||
const checksumsPath = options.checksumsPath ?? path.join(path.dirname(feedPath), "checksums.json");
|
||||
const checksumsSignaturePath = options.checksumsSignaturePath ?? `${checksumsPath}.sig`;
|
||||
const publicKeyPem = String(options.publicKeyPem ?? "");
|
||||
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
|
||||
const allowUnsigned = options.allowUnsigned === true;
|
||||
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||
|
||||
const payloadRaw = await fs.readFile(feedPath, "utf8");
|
||||
|
||||
if (!allowUnsigned) {
|
||||
const signatureRaw = await fs.readFile(signaturePath, "utf8");
|
||||
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||
throw new Error(`Feed signature verification failed for local feed: ${feedPath}`);
|
||||
}
|
||||
|
||||
if (verifyChecksumManifest) {
|
||||
const checksumsRaw = await fs.readFile(checksumsPath, "utf8");
|
||||
const checksumsSignatureRaw = await fs.readFile(checksumsSignaturePath, "utf8");
|
||||
|
||||
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
||||
throw new Error(`Checksum manifest signature verification failed: ${checksumsPath}`);
|
||||
}
|
||||
|
||||
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
||||
const checksumFeedEntry = options.checksumFeedEntry ?? path.basename(feedPath);
|
||||
const checksumSignatureEntry = options.checksumSignatureEntry ?? path.basename(signaturePath);
|
||||
const expectedEntries = /** @type {Record<string, string>} */ ({
|
||||
[checksumFeedEntry]: payloadRaw,
|
||||
[checksumSignatureEntry]: signatureRaw,
|
||||
});
|
||||
|
||||
if (options.checksumPublicKeyEntry) {
|
||||
expectedEntries[options.checksumPublicKeyEntry] = publicKeyPem;
|
||||
}
|
||||
|
||||
verifyChecksums(checksumsManifest, expectedEntries);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
if (!isValidFeedPayload(payload)) {
|
||||
throw new Error(`Invalid advisory feed format: ${feedPath}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedUrl
|
||||
* @param {{
|
||||
* signatureUrl?: string;
|
||||
* checksumsUrl?: string;
|
||||
* checksumsSignatureUrl?: string;
|
||||
* publicKeyPem?: string;
|
||||
* checksumsPublicKeyPem?: string;
|
||||
* allowUnsigned?: boolean;
|
||||
* verifyChecksumManifest?: boolean;
|
||||
* checksumFeedEntry?: string;
|
||||
* checksumSignatureEntry?: string;
|
||||
* }} [options]
|
||||
* @returns {Promise<import("./types.ts").FeedPayload | null>}
|
||||
*/
|
||||
export async function loadRemoteFeed(feedUrl, options = {}) {
|
||||
// Use secure fetch with TLS 1.2+ enforcement and domain validation
|
||||
const fetchFn = secureFetch;
|
||||
if (typeof fetchFn !== "function") return null;
|
||||
|
||||
const signatureUrl = options.signatureUrl ?? `${feedUrl}.sig`;
|
||||
const checksumsUrl = options.checksumsUrl ?? defaultChecksumsUrl(feedUrl);
|
||||
const checksumsSignatureUrl = options.checksumsSignatureUrl ?? `${checksumsUrl}.sig`;
|
||||
const publicKeyPem = String(options.publicKeyPem ?? "");
|
||||
const checksumsPublicKeyPem = String(options.checksumsPublicKeyPem ?? publicKeyPem);
|
||||
const allowUnsigned = options.allowUnsigned === true;
|
||||
const verifyChecksumManifest = options.verifyChecksumManifest !== false;
|
||||
|
||||
try {
|
||||
const payloadRaw = await fetchText(fetchFn, feedUrl);
|
||||
if (!payloadRaw) return null;
|
||||
|
||||
if (!allowUnsigned) {
|
||||
const signatureRaw = await fetchText(fetchFn, signatureUrl);
|
||||
if (!signatureRaw) return null;
|
||||
|
||||
if (!verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only verify checksums if explicitly requested AND both checksum files are available.
|
||||
// Note: Many upstream workflows (e.g., GitHub raw content) don't publish checksums.json,
|
||||
// so we gracefully skip verification when these files are missing.
|
||||
if (verifyChecksumManifest) {
|
||||
const checksumsRaw = await fetchText(fetchFn, checksumsUrl);
|
||||
const checksumsSignatureRaw = await fetchText(fetchFn, checksumsSignatureUrl);
|
||||
|
||||
// Only proceed if BOTH checksum files are present
|
||||
if (checksumsRaw && checksumsSignatureRaw) {
|
||||
if (!verifySignedPayload(checksumsRaw, checksumsSignatureRaw, checksumsPublicKeyPem)) {
|
||||
return null; // Fail-closed: invalid signature
|
||||
}
|
||||
|
||||
const checksumsManifest = parseChecksumsManifest(checksumsRaw);
|
||||
// Derive checksum entry names from actual URLs (supports any filename, not just feed.json)
|
||||
const checksumFeedEntry = options.checksumFeedEntry ?? safeBasename(feedUrl, "feed.json");
|
||||
const checksumSignatureEntry = options.checksumSignatureEntry ?? safeBasename(signatureUrl, "feed.json.sig");
|
||||
verifyChecksums(checksumsManifest, {
|
||||
[checksumFeedEntry]: payloadRaw,
|
||||
[checksumSignatureEntry]: signatureRaw,
|
||||
});
|
||||
}
|
||||
// If checksum files missing: continue without checksum verification
|
||||
// (feed signature was already verified above at line 328)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
if (!isValidFeedPayload(payload)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Security policy violations (invalid URLs, non-HTTPS, disallowed domains) return null
|
||||
// to allow graceful fallback to local feed
|
||||
if (error instanceof SecurityPolicyError) {
|
||||
return null;
|
||||
}
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isObject, normalizeSkillName, uniqueStrings } from "./utils.mjs";
|
||||
import { versionMatches } from "./version.mjs";
|
||||
import { parseAffectedSpecifier } from "./feed.mjs";
|
||||
import type { Advisory, FeedPayload, InstalledSkill, AdvisoryMatch } from "./types.ts";
|
||||
|
||||
export async function discoverInstalledSkills(installRoot: string): Promise<InstalledSkill[]> {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(installRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills: InstalledSkill[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const fallbackName = entry.name;
|
||||
const skillDir = path.join(installRoot, entry.name);
|
||||
const skillJsonPath = path.join(skillDir, "skill.json");
|
||||
|
||||
let skillName = fallbackName;
|
||||
let version: string | null = "unknown";
|
||||
|
||||
try {
|
||||
const rawSkillJson = await fs.readFile(skillJsonPath, "utf8");
|
||||
const parsedSkillJson = JSON.parse(rawSkillJson);
|
||||
if (isObject(parsedSkillJson) && typeof parsedSkillJson.name === "string" && parsedSkillJson.name.trim()) {
|
||||
skillName = parsedSkillJson.name.trim();
|
||||
}
|
||||
if (
|
||||
isObject(parsedSkillJson) &&
|
||||
typeof parsedSkillJson.version === "string" &&
|
||||
parsedSkillJson.version.trim()
|
||||
) {
|
||||
version = parsedSkillJson.version.trim();
|
||||
}
|
||||
} catch {
|
||||
// best-effort scan: keep fallback directory name when skill.json is missing or invalid
|
||||
}
|
||||
|
||||
skills.push({ name: skillName, dirName: entry.name, version });
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
export function affectedSpecifierMatchesSkill(rawSpecifier: string, skill: InstalledSkill): boolean {
|
||||
const parsed = parseAffectedSpecifier(rawSpecifier);
|
||||
if (!parsed) return false;
|
||||
|
||||
const specName = normalizeSkillName(parsed.name);
|
||||
const skillName = normalizeSkillName(skill.name);
|
||||
if (specName !== skillName) return false;
|
||||
|
||||
return versionMatches(skill.version, parsed.versionSpec);
|
||||
}
|
||||
|
||||
export function advisoryMatchesSkill(advisory: Advisory, skill: InstalledSkill): string[] {
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
const matches = affected.filter((specifier) => affectedSpecifierMatchesSkill(specifier, skill));
|
||||
return uniqueStrings(matches);
|
||||
}
|
||||
|
||||
export function findMatches(feed: FeedPayload, installedSkills: InstalledSkill[]): AdvisoryMatch[] {
|
||||
const matches: AdvisoryMatch[] = [];
|
||||
|
||||
for (const advisory of feed.advisories) {
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
for (const skill of installedSkills) {
|
||||
const matchedAffected = advisoryMatchesSkill(advisory, skill);
|
||||
if (matchedAffected.length === 0) continue;
|
||||
matches.push({ advisory, skill, matchedAffected });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function matchKey(match: AdvisoryMatch): string {
|
||||
const normalizedSkillName = normalizeSkillName(match.skill.name);
|
||||
const version = match.skill.version ?? "unknown";
|
||||
const advisoryId =
|
||||
match.advisory.id ??
|
||||
`${match.advisory.title ?? "untitled"}::${match.advisory.published ?? match.advisory.updated ?? "unknown-ts"}`;
|
||||
return `${advisoryId}::${normalizedSkillName}@${version}`;
|
||||
}
|
||||
|
||||
export function looksMalicious(advisory: Advisory): boolean {
|
||||
const type = String(advisory.type ?? "").toLowerCase();
|
||||
const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase();
|
||||
|
||||
if (type === "malicious_skill" || type === "malicious_plugin") return true;
|
||||
if (/\b(malicious|exfiltrat(e|ion)|backdoor|trojan|credential theft|stealer)\b/.test(combined)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function looksRemovalRecommended(advisory: Advisory): boolean {
|
||||
const combined = `${advisory.action ?? ""} ${advisory.title ?? ""} ${advisory.description ?? ""}`.toLowerCase();
|
||||
return /\b(remove|uninstall|delete|disable|do not use|quarantine)\b/.test(combined);
|
||||
}
|
||||
|
||||
export function buildAlertMessage(matches: AdvisoryMatch[], installRoot: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("CLAWSEC ALERT: advisory feed matches installed skill(s).");
|
||||
lines.push("Affected skill advisories:");
|
||||
|
||||
const MAX_LISTED = 8;
|
||||
for (const match of matches.slice(0, MAX_LISTED)) {
|
||||
const severity = String(match.advisory.severity ?? "unknown").toUpperCase();
|
||||
const advisoryId = match.advisory.id ?? "unknown-id";
|
||||
const version = match.skill.version ?? "unknown";
|
||||
const matched = match.matchedAffected.join(", ");
|
||||
lines.push(
|
||||
`- [${severity}] ${advisoryId} -> ${match.skill.name}@${version}` +
|
||||
(matched ? ` (matched: ${matched})` : ""),
|
||||
);
|
||||
if (match.advisory.action) {
|
||||
lines.push(` Action: ${match.advisory.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > MAX_LISTED) {
|
||||
lines.push(`- ... ${matches.length - MAX_LISTED} additional match(es) not shown`);
|
||||
}
|
||||
|
||||
const removalMatches = matches.filter((entry) => looksMalicious(entry.advisory) || looksRemovalRecommended(entry.advisory));
|
||||
if (removalMatches.length > 0) {
|
||||
const impactedSkills = uniqueStrings(removalMatches.map((entry) => entry.skill.name));
|
||||
const impactedDirs = uniqueStrings(removalMatches.map((entry) => entry.skill.dirName));
|
||||
lines.push("");
|
||||
lines.push("Recommendation: one or more matches indicate potentially malicious or unsafe skills.");
|
||||
lines.push("Best practice: remove or disable affected skills only after explicit user approval.");
|
||||
lines.push(
|
||||
"Double-confirmation policy: treat the install request as first intent and require an additional explicit confirmation with this advisory context.",
|
||||
);
|
||||
lines.push(`Approval needed: ask the user to approve removal of: ${impactedSkills.join(", ")}.`);
|
||||
lines.push("Candidate removal paths:");
|
||||
for (const dir of impactedDirs) {
|
||||
lines.push(`- ${path.join(installRoot, dir)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push("");
|
||||
lines.push("Recommendation: review advisories and update/remove affected skills as directed.");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isObject, uniqueStrings } from "./utils.mjs";
|
||||
import type { AdvisoryState } from "./types.ts";
|
||||
|
||||
export const DEFAULT_STATE: AdvisoryState = {
|
||||
schema_version: "1.1",
|
||||
known_advisories: [],
|
||||
last_feed_check: null,
|
||||
last_feed_updated: null,
|
||||
last_hook_scan: null,
|
||||
notified_matches: {},
|
||||
};
|
||||
|
||||
export function normalizeState(raw: unknown): AdvisoryState {
|
||||
if (!isObject(raw)) {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
|
||||
const knownAdvisories = Array.isArray(raw.known_advisories)
|
||||
? uniqueStrings(raw.known_advisories.filter((value): value is string => typeof value === "string" && value.trim() !== ""))
|
||||
: [];
|
||||
|
||||
const notifiedMatches: Record<string, string> = {};
|
||||
if (isObject(raw.notified_matches)) {
|
||||
for (const [key, value] of Object.entries(raw.notified_matches)) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
notifiedMatches[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: "1.1",
|
||||
known_advisories: knownAdvisories,
|
||||
last_feed_check: typeof raw.last_feed_check === "string" ? raw.last_feed_check : null,
|
||||
last_feed_updated: typeof raw.last_feed_updated === "string" ? raw.last_feed_updated : null,
|
||||
last_hook_scan: typeof raw.last_hook_scan === "string" ? raw.last_hook_scan : null,
|
||||
notified_matches: notifiedMatches,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadState(stateFile: string): Promise<AdvisoryState> {
|
||||
try {
|
||||
const raw = await fs.readFile(stateFile, "utf8");
|
||||
return normalizeState(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistState(stateFile: string, state: AdvisoryState): Promise<void> {
|
||||
const normalized = normalizeState(state);
|
||||
await fs.mkdir(path.dirname(stateFile), { recursive: true });
|
||||
const tmpFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`;
|
||||
await fs.writeFile(tmpFile, `${JSON.stringify(normalized, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.rename(tmpFile, stateFile);
|
||||
try {
|
||||
await fs.chmod(stateFile, 0o600);
|
||||
} catch (err: unknown) {
|
||||
const code = err instanceof Error && "code" in err ? (err as { code: string }).code : undefined;
|
||||
if (code === "ENOTSUP" || code === "EPERM") {
|
||||
console.warn(
|
||||
`Warning: chmod 0600 failed for ${stateFile} (${code}). ` +
|
||||
"File permissions may not be enforced on this platform/filesystem.",
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
export type HookEvent = {
|
||||
type?: string;
|
||||
action?: string;
|
||||
messages?: string[];
|
||||
};
|
||||
|
||||
export type Advisory = {
|
||||
id?: string;
|
||||
severity?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
affected?: string[];
|
||||
};
|
||||
|
||||
export type FeedPayload = {
|
||||
version: string;
|
||||
updated?: string;
|
||||
advisories: Advisory[];
|
||||
};
|
||||
|
||||
export type InstalledSkill = {
|
||||
name: string;
|
||||
dirName: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type AdvisoryMatch = {
|
||||
advisory: Advisory;
|
||||
skill: InstalledSkill;
|
||||
matchedAffected: string[];
|
||||
};
|
||||
|
||||
export type AdvisoryState = {
|
||||
schema_version: string;
|
||||
known_advisories: string[];
|
||||
last_feed_check: string | null;
|
||||
last_feed_updated: string | null;
|
||||
last_hook_scan: string | null;
|
||||
notified_matches: Record<string, string>;
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is Record<string, unknown>}
|
||||
*/
|
||||
export function isObject(value) {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeSkillName(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} values
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function uniqueStrings(values) {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* @param {string} version
|
||||
* @returns {[number, number, number] | null}
|
||||
*/
|
||||
export function parseSemver(version) {
|
||||
const cleaned = String(version ?? "")
|
||||
.trim()
|
||||
.replace(/^v/i, "")
|
||||
.split("-")[0];
|
||||
const parts = cleaned.split(".");
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const normalized = parts.slice(0, 3).map((part) => Number.parseInt(part, 10));
|
||||
while (normalized.length < 3) {
|
||||
normalized.push(0);
|
||||
}
|
||||
|
||||
if (normalized.some((part) => Number.isNaN(part))) {
|
||||
return null;
|
||||
}
|
||||
return /** @type {[number, number, number]} */ (normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} left
|
||||
* @param {string} right
|
||||
* @returns {number | null}
|
||||
*/
|
||||
export function compareSemver(left, right) {
|
||||
const a = parseSemver(left);
|
||||
const b = parseSemver(right);
|
||||
if (!a || !b) return null;
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a[index] > b[index]) return 1;
|
||||
if (a[index] < b[index]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeRegex(value) {
|
||||
return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} version
|
||||
* @param {string} rawSpec
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function versionMatches(version, rawSpec) {
|
||||
const spec = String(rawSpec ?? "").trim();
|
||||
if (!spec || spec === "*" || spec.toLowerCase() === "any") return true;
|
||||
if (!version || String(version).trim().toLowerCase() === "unknown") return false;
|
||||
|
||||
const normalizedVersion = String(version).trim().replace(/^v/i, "");
|
||||
|
||||
if (spec.includes("*")) {
|
||||
const regex = new RegExp(`^${escapeRegex(spec).replace(/\\\*/g, ".*")}$`);
|
||||
return regex.test(normalizedVersion);
|
||||
}
|
||||
|
||||
const comparatorMatch = spec.match(/^(>=|<=|>|<|=)\s*(.+)$/);
|
||||
if (comparatorMatch) {
|
||||
const operator = comparatorMatch[1];
|
||||
const targetVersion = comparatorMatch[2].trim();
|
||||
const compared = compareSemver(normalizedVersion, targetVersion);
|
||||
if (compared === null) return false;
|
||||
if (operator === ">=") return compared >= 0;
|
||||
if (operator === "<=") return compared <= 0;
|
||||
if (operator === ">") return compared > 0;
|
||||
if (operator === "<") return compared < 0;
|
||||
return compared === 0;
|
||||
}
|
||||
|
||||
if (spec.startsWith("^")) {
|
||||
const target = parseSemver(spec.slice(1));
|
||||
const current = parseSemver(normalizedVersion);
|
||||
if (!target || !current) return false;
|
||||
if (current[0] !== target[0]) return false;
|
||||
if (target[0] === 0 && current[1] !== target[1]) return false;
|
||||
return compareSemver(normalizedVersion, spec.slice(1)) !== -1;
|
||||
}
|
||||
|
||||
if (spec.startsWith("~")) {
|
||||
const target = parseSemver(spec.slice(1));
|
||||
const current = parseSemver(normalizedVersion);
|
||||
if (!target || !current) return false;
|
||||
return (
|
||||
current[0] === target[0] &&
|
||||
current[1] === target[1] &&
|
||||
compareSemver(normalizedVersion, spec.slice(1)) !== -1
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedVersion === spec || normalizedVersion === spec.replace(/^v/i, "");
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_FILES = ["feed-signing-public.pem", "feed.json", "feed.json.sig"];
|
||||
|
||||
function usage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/generate_checksums_json.mjs --out advisories/checksums.json [--base advisories] [--file feed.json --file feed.json.sig ...]",
|
||||
"",
|
||||
"Defaults:",
|
||||
" --base <dirname(--out)>",
|
||||
` --file ${DEFAULT_FILES.join(" --file ")}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = { files: [] };
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--out") {
|
||||
parsed.outPath = argv[++i];
|
||||
} else if (token === "--base") {
|
||||
parsed.baseDir = argv[++i];
|
||||
} else if (token === "--file") {
|
||||
parsed.files.push(argv[++i]);
|
||||
} else if (token === "-h" || token === "--help") {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256Hex(buffer) {
|
||||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { outPath, baseDir, files, help } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!outPath) {
|
||||
usage();
|
||||
throw new Error("Missing required argument: --out");
|
||||
}
|
||||
|
||||
const resolvedBase = path.resolve(baseDir ?? path.dirname(outPath));
|
||||
const fileList = files.length > 0 ? files : DEFAULT_FILES;
|
||||
|
||||
const checksums = {};
|
||||
|
||||
for (const relativePath of [...fileList].sort((a, b) => a.localeCompare(b))) {
|
||||
const absolutePath = path.resolve(resolvedBase, relativePath);
|
||||
const content = await fs.readFile(absolutePath);
|
||||
checksums[relativePath] = sha256Hex(content);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
schema_version: "1.0",
|
||||
algorithm: "sha256",
|
||||
files: checksums,
|
||||
};
|
||||
|
||||
await fs.writeFile(`${outPath}`, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
process.stdout.write(`Wrote ${outPath}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
|
||||
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
|
||||
import {
|
||||
defaultChecksumsUrl,
|
||||
parseAffectedSpecifier,
|
||||
loadLocalFeed,
|
||||
loadRemoteFeed,
|
||||
} from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||
const DEFAULT_SUITE_DIR = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const DEFAULT_LOCAL_FEED = path.join(DEFAULT_SUITE_DIR, "advisories", "feed.json");
|
||||
const DEFAULT_LOCAL_FEED_SIG = `${DEFAULT_LOCAL_FEED}.sig`;
|
||||
const DEFAULT_LOCAL_FEED_CHECKSUMS = path.join(DEFAULT_SUITE_DIR, "advisories", "checksums.json");
|
||||
const DEFAULT_LOCAL_FEED_CHECKSUMS_SIG = `${DEFAULT_LOCAL_FEED_CHECKSUMS}.sig`;
|
||||
const DEFAULT_FEED_PUBLIC_KEY = path.join(DEFAULT_SUITE_DIR, "advisories", "feed-signing-public.pem");
|
||||
const EXIT_CONFIRM_REQUIRED = 42;
|
||||
|
||||
function printUsage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/guarded_skill_install.mjs --skill <skill-name> [--version <version>] [--confirm-advisory] [--dry-run]",
|
||||
"",
|
||||
"Examples:",
|
||||
" node scripts/guarded_skill_install.mjs --skill helper-plus --version 1.0.1",
|
||||
" node scripts/guarded_skill_install.mjs --skill helper-plus --version 1.0.1 --confirm-advisory",
|
||||
"",
|
||||
"Exit codes:",
|
||||
" 0 success / no advisory block",
|
||||
" 42 advisory matched and second confirmation is required",
|
||||
" 1 error",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
skill: "",
|
||||
version: "",
|
||||
confirmAdvisory: false,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === "--skill") {
|
||||
parsed.skill = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--version") {
|
||||
parsed.version = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm-advisory") {
|
||||
parsed.confirmAdvisory = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--help" || token === "-h") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
|
||||
if (!parsed.skill) {
|
||||
throw new Error("Missing required argument: --skill");
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(parsed.skill)) {
|
||||
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function affectedSpecifierMatches(specifier, skillName, version) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) return false;
|
||||
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false;
|
||||
return versionMatches(version, parsed.versionSpec);
|
||||
}
|
||||
|
||||
function affectedSpecifierMatchesWithoutVersion(specifier, skillName) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) return false;
|
||||
return normalizeSkillName(parsed.name) === normalizeSkillName(skillName);
|
||||
}
|
||||
|
||||
function advisoryLooksHighRisk(advisory) {
|
||||
const type = String(advisory.type ?? "").toLowerCase();
|
||||
const severity = String(advisory.severity ?? "").toLowerCase();
|
||||
const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase();
|
||||
if (type === "malicious_skill" || type === "malicious_plugin") return true;
|
||||
if (/\b(malicious|exfiltrate|exfiltration|backdoor|trojan|stealer|credential theft)\b/.test(combined)) return true;
|
||||
if (/\b(remove|uninstall|disable|do not use|quarantine)\b/.test(combined)) return true;
|
||||
if (severity === "critical") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadFeed() {
|
||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||
const feedSignatureUrl = process.env.CLAWSEC_FEED_SIG_URL || `${feedUrl}.sig`;
|
||||
const feedChecksumsUrl = process.env.CLAWSEC_FEED_CHECKSUMS_URL || defaultChecksumsUrl(feedUrl);
|
||||
const feedChecksumsSignatureUrl = process.env.CLAWSEC_FEED_CHECKSUMS_SIG_URL || `${feedChecksumsUrl}.sig`;
|
||||
const localFeedPath = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED;
|
||||
const localFeedSigPath = process.env.CLAWSEC_LOCAL_FEED_SIG || DEFAULT_LOCAL_FEED_SIG;
|
||||
const localFeedChecksumsPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS || DEFAULT_LOCAL_FEED_CHECKSUMS;
|
||||
const localFeedChecksumsSigPath = process.env.CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG || DEFAULT_LOCAL_FEED_CHECKSUMS_SIG;
|
||||
const feedPublicKeyPath = process.env.CLAWSEC_FEED_PUBLIC_KEY || DEFAULT_FEED_PUBLIC_KEY;
|
||||
const allowUnsigned = process.env.CLAWSEC_ALLOW_UNSIGNED_FEED === "1";
|
||||
const verifyChecksumManifest = process.env.CLAWSEC_VERIFY_CHECKSUM_MANIFEST !== "0";
|
||||
|
||||
if (allowUnsigned) {
|
||||
process.stderr.write(
|
||||
"WARNING: CLAWSEC_ALLOW_UNSIGNED_FEED=1 is enabled. This temporary migration compatibility bypass should be removed once signed feed artifacts are available.\n",
|
||||
);
|
||||
}
|
||||
|
||||
const publicKeyPem = allowUnsigned ? "" : await fs.readFile(feedPublicKeyPath, "utf8");
|
||||
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl, {
|
||||
signatureUrl: feedSignatureUrl,
|
||||
checksumsUrl: feedChecksumsUrl,
|
||||
checksumsSignatureUrl: feedChecksumsSignatureUrl,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
});
|
||||
if (remoteFeed) return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
||||
|
||||
const localFeed = await loadLocalFeed(localFeedPath, {
|
||||
signaturePath: localFeedSigPath,
|
||||
checksumsPath: localFeedChecksumsPath,
|
||||
checksumsSignaturePath: localFeedChecksumsSigPath,
|
||||
publicKeyPem,
|
||||
checksumsPublicKeyPem: publicKeyPem,
|
||||
allowUnsigned,
|
||||
verifyChecksumManifest,
|
||||
checksumPublicKeyEntry: path.basename(feedPublicKeyPath),
|
||||
});
|
||||
return { feed: localFeed, source: `local:${localFeedPath}` };
|
||||
}
|
||||
|
||||
function findMatches(feed, skillName, version) {
|
||||
const advisories = Array.isArray(feed.advisories) ? feed.advisories : [];
|
||||
const matches = [];
|
||||
|
||||
for (const advisory of advisories) {
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
const matchedAffected = uniqueStrings(
|
||||
affected.filter((specifier) =>
|
||||
version
|
||||
? affectedSpecifierMatches(specifier, skillName, version)
|
||||
: affectedSpecifierMatchesWithoutVersion(specifier, skillName),
|
||||
),
|
||||
);
|
||||
|
||||
if (matchedAffected.length > 0) {
|
||||
matches.push({ advisory, matchedAffected });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function printMatches(matches, skillName, version) {
|
||||
process.stdout.write("Advisory matches detected for requested install target.\n");
|
||||
process.stdout.write(`Target: ${skillName}${version ? `@${version}` : ""}\n`);
|
||||
|
||||
for (const entry of matches) {
|
||||
const advisory = entry.advisory;
|
||||
const severity = String(advisory.severity ?? "unknown").toUpperCase();
|
||||
const advisoryId = advisory.id ?? "unknown-id";
|
||||
const title = advisory.title ?? "Untitled advisory";
|
||||
process.stdout.write(`- [${severity}] ${advisoryId}: ${title}\n`);
|
||||
process.stdout.write(` matched: ${entry.matchedAffected.join(", ")}\n`);
|
||||
if (advisory.action) {
|
||||
process.stdout.write(` action: ${advisory.action}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runInstall(skillName, version) {
|
||||
const target = version ? `${skillName}@${version}` : skillName;
|
||||
process.stdout.write(`Install target: ${target}\n`);
|
||||
|
||||
const result = spawnSync("npx", ["clawhub@latest", "install", target], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const { feed, source } = await loadFeed();
|
||||
const matches = findMatches(feed, args.skill, args.version);
|
||||
const highRisk = matches.some((entry) => advisoryLooksHighRisk(entry.advisory));
|
||||
|
||||
process.stdout.write(`Advisory source: ${source}\n`);
|
||||
|
||||
if (!args.version) {
|
||||
process.stdout.write(
|
||||
"No --version provided. Conservatively matching any advisory for the requested skill name.\n",
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
printMatches(matches, args.skill, args.version);
|
||||
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write("Install request recognized as first confirmation.\n");
|
||||
process.stdout.write("Additional explicit confirmation is required with advisory context.\n");
|
||||
|
||||
if (!args.confirmAdvisory) {
|
||||
process.stdout.write(
|
||||
"Re-run with --confirm-advisory to proceed after the user explicitly confirms.\n",
|
||||
);
|
||||
process.exit(EXIT_CONFIRM_REQUIRED);
|
||||
}
|
||||
process.stdout.write("Second confirmation provided via --confirm-advisory.\n");
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
process.stdout.write("Dry run only; install command was not executed.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (highRisk) {
|
||||
process.stdout.write(
|
||||
"High-risk advisory context acknowledged. Proceeding only because --confirm-advisory was provided.\n",
|
||||
);
|
||||
}
|
||||
|
||||
runInstall(args.skill, args.version);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const JOB_NAME = process.env.CLAWSEC_ADVISORY_CRON_NAME?.trim() || "ClawSec Advisory Scan";
|
||||
const JOB_EVERY = process.env.CLAWSEC_ADVISORY_CRON_EVERY?.trim() || "6h";
|
||||
const JOB_DESCRIPTION =
|
||||
"Trigger a periodic ClawSec advisory scan in the main session and ask for approval before removing flagged skills.";
|
||||
const SYSTEM_EVENT =
|
||||
"Run ClawSec advisory scan. If installed skills are flagged as malicious or removal is recommended, notify the user and request explicit approval before any removal.";
|
||||
|
||||
function sh(cmd, args) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const details = (result.stderr || result.stdout || "").trim();
|
||||
throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function requireOpenClawCli() {
|
||||
try {
|
||||
sh("openclaw", ["--version"]);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
|
||||
`Original error: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingJobId(jobsPayload) {
|
||||
if (!jobsPayload || !Array.isArray(jobsPayload.jobs)) return null;
|
||||
const existing = jobsPayload.jobs.find((job) => job && job.name === JOB_NAME);
|
||||
return existing?.id ?? null;
|
||||
}
|
||||
|
||||
function addJob() {
|
||||
const out = sh("openclaw", [
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
JOB_NAME,
|
||||
"--description",
|
||||
JOB_DESCRIPTION,
|
||||
"--every",
|
||||
JOB_EVERY,
|
||||
"--session",
|
||||
"main",
|
||||
"--system-event",
|
||||
SYSTEM_EVENT,
|
||||
"--wake",
|
||||
"now",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(out);
|
||||
return payload?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function editJob(jobId) {
|
||||
sh("openclaw", [
|
||||
"cron",
|
||||
"edit",
|
||||
jobId,
|
||||
"--name",
|
||||
JOB_NAME,
|
||||
"--description",
|
||||
JOB_DESCRIPTION,
|
||||
"--enable",
|
||||
"--every",
|
||||
JOB_EVERY,
|
||||
"--session",
|
||||
"main",
|
||||
"--system-event",
|
||||
SYSTEM_EVENT,
|
||||
"--wake",
|
||||
"now",
|
||||
]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
requireOpenClawCli();
|
||||
|
||||
const jobsOut = sh("openclaw", ["cron", "list", "--json"]);
|
||||
const jobsPayload = JSON.parse(jobsOut);
|
||||
const existingJobId = findExistingJobId(jobsPayload);
|
||||
|
||||
if (existingJobId) {
|
||||
editJob(existingJobId);
|
||||
process.stdout.write(`Updated cron job ${existingJobId}: ${JOB_NAME}\n`);
|
||||
} else {
|
||||
const createdId = addJob();
|
||||
if (createdId) {
|
||||
process.stdout.write(`Created cron job ${createdId}: ${JOB_NAME}\n`);
|
||||
} else {
|
||||
process.stdout.write(`Created cron job: ${JOB_NAME}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Schedule: every ${JOB_EVERY}\n`);
|
||||
process.stdout.write("Session target: main (system event + wake now)\n");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HOOK_NAME = "clawsec-advisory-guardian";
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SUITE_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
const SOURCE_HOOK_DIR = path.join(SUITE_DIR, "hooks", HOOK_NAME);
|
||||
const HOOKS_ROOT = path.join(os.homedir(), ".openclaw", "hooks");
|
||||
const TARGET_HOOK_DIR = path.join(HOOKS_ROOT, HOOK_NAME);
|
||||
|
||||
function sh(cmd, args) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const details = (result.stderr || result.stdout || "").trim();
|
||||
throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function requireOpenClawCli() {
|
||||
try {
|
||||
sh("openclaw", ["--version"]);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
|
||||
`Original error: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSourceHookExists() {
|
||||
const requiredFiles = [
|
||||
"HOOK.md",
|
||||
"handler.ts",
|
||||
"lib/utils.mjs",
|
||||
"lib/version.mjs",
|
||||
"lib/feed.mjs",
|
||||
];
|
||||
for (const file of requiredFiles) {
|
||||
const fullPath = path.join(SOURCE_HOOK_DIR, file);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Missing required hook file: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installHookFiles() {
|
||||
fs.mkdirSync(HOOKS_ROOT, { recursive: true });
|
||||
fs.rmSync(TARGET_HOOK_DIR, { recursive: true, force: true });
|
||||
fs.cpSync(SOURCE_HOOK_DIR, TARGET_HOOK_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function enableHook() {
|
||||
sh("openclaw", ["hooks", "enable", HOOK_NAME]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
assertSourceHookExists();
|
||||
requireOpenClawCli();
|
||||
installHookFiles();
|
||||
enableHook();
|
||||
|
||||
process.stdout.write(`Installed hook files to: ${TARGET_HOOK_DIR}\n`);
|
||||
process.stdout.write(`Enabled hook: ${HOOK_NAME}\n`);
|
||||
process.stdout.write("Restart your OpenClaw gateway process so the hook is loaded.\n");
|
||||
process.stdout.write("After restart, run /new once to trigger an immediate advisory scan.\n");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
function usage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/sign_detached_ed25519.mjs --key <private-key.pem> --in <file> --out <file.sig>",
|
||||
"",
|
||||
"Signs <file> with Ed25519 private key and writes base64 detached signature to <file.sig>.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--key") {
|
||||
parsed.keyPath = argv[++i];
|
||||
} else if (token === "--in") {
|
||||
parsed.inPath = argv[++i];
|
||||
} else if (token === "--out") {
|
||||
parsed.outPath = argv[++i];
|
||||
} else if (token === "-h" || token === "--help") {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { keyPath, inPath, outPath, help } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!keyPath || !inPath || !outPath) {
|
||||
usage();
|
||||
throw new Error("Missing required arguments: --key, --in, --out");
|
||||
}
|
||||
|
||||
const privateKeyPem = await fs.readFile(keyPath, "utf8");
|
||||
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
const data = await fs.readFile(inPath);
|
||||
const signature = crypto.sign(null, data, privateKey);
|
||||
const signatureBase64 = signature.toString("base64");
|
||||
|
||||
await fs.writeFile(outPath, `${signatureBase64}\n`, "utf8");
|
||||
process.stdout.write(`Signed ${inPath} -> ${outPath}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
function usage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/verify_detached_ed25519.mjs --key <public-key.pem> --in <file> --sig <file.sig>",
|
||||
"",
|
||||
"Verifies Ed25519 detached signature against <file>.",
|
||||
"Exits 0 on success, 1 on verification failure.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--key") {
|
||||
parsed.keyPath = argv[++i];
|
||||
} else if (token === "--in") {
|
||||
parsed.inPath = argv[++i];
|
||||
} else if (token === "--sig") {
|
||||
parsed.sigPath = argv[++i];
|
||||
} else if (token === "-h" || token === "--help") {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { keyPath, inPath, sigPath, help } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!keyPath || !inPath || !sigPath) {
|
||||
usage();
|
||||
throw new Error("Missing required arguments: --key, --in, --sig");
|
||||
}
|
||||
|
||||
const publicKeyPem = await fs.readFile(keyPath, "utf8");
|
||||
const publicKey = crypto.createPublicKey(publicKeyPem);
|
||||
const data = await fs.readFile(inPath);
|
||||
const signatureRaw = await fs.readFile(sigPath, "utf8");
|
||||
const signature = Buffer.from(signatureRaw.trim(), "base64");
|
||||
|
||||
const valid = crypto.verify(null, data, publicKey, signature);
|
||||
|
||||
if (valid) {
|
||||
process.stdout.write(`Signature valid: ${inPath}\n`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write(`Signature INVALID: ${inPath}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
{
|
||||
"name": "clawsec-suite",
|
||||
"version": "0.0.10",
|
||||
"description": "ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
"keywords": [
|
||||
"security",
|
||||
"skills",
|
||||
"catalog",
|
||||
"installer",
|
||||
"integrity",
|
||||
"advisory",
|
||||
"feed",
|
||||
"threat-intel",
|
||||
"hooks",
|
||||
"approval",
|
||||
"agents",
|
||||
"ai",
|
||||
"suite",
|
||||
"openclaw",
|
||||
"signature",
|
||||
"verification"
|
||||
],
|
||||
"sbom": {
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"required": true,
|
||||
"description": "Suite skill documentation and installation guide"
|
||||
},
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"required": true,
|
||||
"description": "Version history and security improvements changelog"
|
||||
},
|
||||
{
|
||||
"path": "HEARTBEAT.md",
|
||||
"required": true,
|
||||
"description": "Portable heartbeat and update-check procedure"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed.json",
|
||||
"required": true,
|
||||
"description": "Embedded advisory feed seed (merged from clawsec-feed)"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed.json.sig",
|
||||
"required": true,
|
||||
"description": "Detached Ed25519 signature for advisory feed"
|
||||
},
|
||||
{
|
||||
"path": "advisories/checksums.json",
|
||||
"required": true,
|
||||
"description": "SHA-256 checksum manifest for advisory artifacts"
|
||||
},
|
||||
{
|
||||
"path": "advisories/checksums.json.sig",
|
||||
"required": true,
|
||||
"description": "Detached Ed25519 signature for checksum manifest"
|
||||
},
|
||||
{
|
||||
"path": "advisories/feed-signing-public.pem",
|
||||
"required": true,
|
||||
"description": "Pinned Ed25519 public key for feed signature verification"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/HOOK.md",
|
||||
"required": true,
|
||||
"description": "OpenClaw hook metadata for advisory-driven malicious-skill checks"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/handler.ts",
|
||||
"required": true,
|
||||
"description": "OpenClaw hook handler for approval-gated advisory actions with signature verification"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/utils.mjs",
|
||||
"required": true,
|
||||
"description": "Shared utility functions (isObject, normalizeSkillName, uniqueStrings)"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/version.mjs",
|
||||
"required": true,
|
||||
"description": "Shared semver parsing and version matching logic"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/feed.mjs",
|
||||
"required": true,
|
||||
"description": "Advisory feed loading with Ed25519 signature and checksum manifest verification"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/types.ts",
|
||||
"required": true,
|
||||
"description": "TypeScript type definitions for hook and feed structures"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/state.ts",
|
||||
"required": true,
|
||||
"description": "Advisory state persistence and loading"
|
||||
},
|
||||
{
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/matching.ts",
|
||||
"required": true,
|
||||
"description": "Advisory-to-skill matching and alert message generation"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_advisory_hook.mjs",
|
||||
"required": true,
|
||||
"description": "Installer script for enabling the advisory guardian hook"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_advisory_cron.mjs",
|
||||
"required": true,
|
||||
"description": "Installer script for optional periodic advisory scan cron"
|
||||
},
|
||||
{
|
||||
"path": "scripts/guarded_skill_install.mjs",
|
||||
"required": true,
|
||||
"description": "Two-step confirmation installer with signature verification that blocks risky skill installs"
|
||||
},
|
||||
{
|
||||
"path": "scripts/sign_detached_ed25519.mjs",
|
||||
"required": false,
|
||||
"description": "Utility script for generating Ed25519 detached signatures"
|
||||
},
|
||||
{
|
||||
"path": "scripts/verify_detached_ed25519.mjs",
|
||||
"required": false,
|
||||
"description": "Utility script for verifying Ed25519 detached signatures"
|
||||
},
|
||||
{
|
||||
"path": "scripts/generate_checksums_json.mjs",
|
||||
"required": false,
|
||||
"description": "Utility script for generating SHA-256 checksum manifests"
|
||||
}
|
||||
]
|
||||
},
|
||||
"embedded_components": {
|
||||
"clawsec-feed": {
|
||||
"source_skill": "clawsec-feed",
|
||||
"source_version": "0.0.4",
|
||||
"paths": [
|
||||
"advisories/feed.json",
|
||||
"advisories/feed.json.sig",
|
||||
"advisories/checksums.json",
|
||||
"advisories/checksums.json.sig",
|
||||
"advisories/feed-signing-public.pem"
|
||||
],
|
||||
"capabilities": [
|
||||
"advisory-feed monitoring",
|
||||
"new-advisory detection",
|
||||
"affected-skill cross-reference",
|
||||
"approval-gated malicious-skill removal recommendations",
|
||||
"double-confirmation gating for risky skill installs",
|
||||
"Ed25519 signature verification",
|
||||
"checksum manifest verification"
|
||||
],
|
||||
"standalone_available": true,
|
||||
"deprecation_plan": "standalone skill may be retired after suite migration is verified"
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"description": "Available protections in the ClawSec suite",
|
||||
"base_url": "https://clawsec.prompt.security/releases/download",
|
||||
"skills": {
|
||||
"clawsec-feed": {
|
||||
"description": "Advisory monitoring is now embedded in clawsec-suite",
|
||||
"integrated_in_suite": true,
|
||||
"standalone_available": true,
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"openclaw-audit-watchdog": {
|
||||
"description": "Automated daily audits with email reporting",
|
||||
"default_install": true,
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot"
|
||||
],
|
||||
"note": "Tailored for OpenClaw/MoltBot family"
|
||||
},
|
||||
"soul-guardian": {
|
||||
"description": "Drift detection and file integrity guard",
|
||||
"default_install": false,
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"clawtributor": {
|
||||
"description": "Community incident reporting (shares anonymized data)",
|
||||
"default_install": false,
|
||||
"requires_explicit_consent": true,
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"emoji": "📦",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"curl",
|
||||
"jq",
|
||||
"shasum",
|
||||
"openssl"
|
||||
]
|
||||
},
|
||||
"triggers": [
|
||||
"clawsec suite",
|
||||
"security suite",
|
||||
"security advisories",
|
||||
"malicious skill alert",
|
||||
"remove malicious skills",
|
||||
"safe skill install",
|
||||
"confirm skill install",
|
||||
"check advisories",
|
||||
"advisory feed",
|
||||
"install security skills",
|
||||
"verify skills",
|
||||
"check skill integrity",
|
||||
"update skills"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Feed verification tests for clawsec-suite.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Signature verification success/failure/tampered cases
|
||||
* - Checksum manifest verification success/failure/tampered cases
|
||||
* - Fail-closed behavior when signatures are missing/invalid
|
||||
* - Temporary compatibility flag behavior
|
||||
*
|
||||
* Run: node skills/clawsec-suite/test/feed_verification.test.mjs
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
|
||||
|
||||
// Dynamic import to ensure we test the actual module
|
||||
const { verifySignedPayload, loadLocalFeed, isValidFeedPayload } = await import(
|
||||
`${LIB_PATH}/feed.mjs`
|
||||
);
|
||||
|
||||
let tempDir;
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function generateEd25519KeyPair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
return { publicKeyPem, privateKeyPem };
|
||||
}
|
||||
|
||||
function signPayload(data, privateKeyPem) {
|
||||
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
|
||||
return signature.toString("base64");
|
||||
}
|
||||
|
||||
function createValidFeed() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
version: "1.0.0",
|
||||
updated: "2026-02-08T12:00:00Z",
|
||||
advisories: [
|
||||
{
|
||||
id: "TEST-001",
|
||||
severity: "high",
|
||||
affected: ["test-skill@1.0.0"],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function createChecksumManifest(files) {
|
||||
const checksums = {};
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
return JSON.stringify(
|
||||
{
|
||||
schema_version: "1.0",
|
||||
algorithm: "sha256",
|
||||
files: checksums,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
async function setupTestDir() {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-test-"));
|
||||
}
|
||||
|
||||
async function cleanupTestDir() {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: verifySignedPayload - valid signature
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testVerifySignedPayload_ValidSignature() {
|
||||
const testName = "verifySignedPayload: valid signature passes";
|
||||
try {
|
||||
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||
const payload = "test payload content";
|
||||
const signature = signPayload(payload, privateKeyPem);
|
||||
|
||||
const result = verifySignedPayload(payload, signature, publicKeyPem);
|
||||
|
||||
if (result === true) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected true, got false");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: verifySignedPayload - invalid signature
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testVerifySignedPayload_InvalidSignature() {
|
||||
const testName = "verifySignedPayload: invalid signature fails";
|
||||
try {
|
||||
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||
const payload = "test payload content";
|
||||
const signature = signPayload(payload, privateKeyPem);
|
||||
|
||||
// Tamper with payload
|
||||
const tamperedPayload = "TAMPERED payload content";
|
||||
const result = verifySignedPayload(tamperedPayload, signature, publicKeyPem);
|
||||
|
||||
if (result === false) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected false for tampered payload, got true");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: verifySignedPayload - wrong key
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testVerifySignedPayload_WrongKey() {
|
||||
const testName = "verifySignedPayload: wrong key fails";
|
||||
try {
|
||||
const keyPair1 = generateEd25519KeyPair();
|
||||
const keyPair2 = generateEd25519KeyPair();
|
||||
const payload = "test payload content";
|
||||
const signature = signPayload(payload, keyPair1.privateKeyPem);
|
||||
|
||||
// Verify with different public key
|
||||
const result = verifySignedPayload(payload, signature, keyPair2.publicKeyPem);
|
||||
|
||||
if (result === false) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected false for wrong key, got true");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: verifySignedPayload - malformed signature
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testVerifySignedPayload_MalformedSignature() {
|
||||
const testName = "verifySignedPayload: malformed signature fails";
|
||||
try {
|
||||
const { publicKeyPem } = generateEd25519KeyPair();
|
||||
const payload = "test payload content";
|
||||
|
||||
const result = verifySignedPayload(payload, "not-valid-base64!!!", publicKeyPem);
|
||||
|
||||
if (result === false) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected false for malformed signature, got true");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: verifySignedPayload - empty signature
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testVerifySignedPayload_EmptySignature() {
|
||||
const testName = "verifySignedPayload: empty signature fails";
|
||||
try {
|
||||
const { publicKeyPem } = generateEd25519KeyPair();
|
||||
const payload = "test payload content";
|
||||
|
||||
const result = verifySignedPayload(payload, "", publicKeyPem);
|
||||
|
||||
if (result === false) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected false for empty signature, got true");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: verifySignedPayload - JSON-wrapped signature format
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testVerifySignedPayload_JsonWrappedSignature() {
|
||||
const testName = "verifySignedPayload: JSON-wrapped signature passes";
|
||||
try {
|
||||
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||
const payload = "test payload content";
|
||||
const signatureBase64 = signPayload(payload, privateKeyPem);
|
||||
const jsonWrapped = JSON.stringify({ signature: signatureBase64 });
|
||||
|
||||
const result = verifySignedPayload(payload, jsonWrapped, publicKeyPem);
|
||||
|
||||
if (result === true) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected true for JSON-wrapped signature, got false");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: loadLocalFeed - valid signed feed
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testLoadLocalFeed_ValidSignedFeed() {
|
||||
const testName = "loadLocalFeed: valid signed feed loads successfully";
|
||||
try {
|
||||
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||
const feedContent = createValidFeed();
|
||||
const feedSignature = signPayload(feedContent, privateKeyPem);
|
||||
|
||||
// Create checksum manifest
|
||||
const checksumManifest = createChecksumManifest({
|
||||
"feed.json": feedContent,
|
||||
"feed.json.sig": feedSignature + "\n",
|
||||
"feed-signing-public.pem": publicKeyPem,
|
||||
});
|
||||
const checksumSignature = signPayload(checksumManifest, privateKeyPem);
|
||||
|
||||
// Write files
|
||||
const feedPath = path.join(tempDir, "feed.json");
|
||||
const sigPath = path.join(tempDir, "feed.json.sig");
|
||||
const checksumPath = path.join(tempDir, "checksums.json");
|
||||
const checksumSigPath = path.join(tempDir, "checksums.json.sig");
|
||||
const keyPath = path.join(tempDir, "feed-signing-public.pem");
|
||||
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||
await fs.writeFile(checksumPath, checksumManifest);
|
||||
await fs.writeFile(checksumSigPath, checksumSignature + "\n");
|
||||
await fs.writeFile(keyPath, publicKeyPem);
|
||||
|
||||
const feed = await loadLocalFeed(feedPath, {
|
||||
signaturePath: sigPath,
|
||||
checksumsPath: checksumPath,
|
||||
checksumsSignaturePath: checksumSigPath,
|
||||
publicKeyPem,
|
||||
verifyChecksumManifest: true,
|
||||
checksumPublicKeyEntry: "feed-signing-public.pem",
|
||||
});
|
||||
|
||||
if (feed && feed.version === "1.0.0" && feed.advisories.length === 1) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Feed did not load with expected content");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: loadLocalFeed - tampered feed fails (fail-closed)
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testLoadLocalFeed_TamperedFeedFails() {
|
||||
const testName = "loadLocalFeed: tampered feed fails (fail-closed)";
|
||||
try {
|
||||
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||
const feedContent = createValidFeed();
|
||||
const feedSignature = signPayload(feedContent, privateKeyPem);
|
||||
|
||||
// Tamper with feed after signing
|
||||
const tamperedFeed = feedContent.replace("TEST-001", "TAMPERED-001");
|
||||
|
||||
const feedPath = path.join(tempDir, "tampered-feed.json");
|
||||
const sigPath = path.join(tempDir, "tampered-feed.json.sig");
|
||||
|
||||
await fs.writeFile(feedPath, tamperedFeed);
|
||||
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||
|
||||
let didFail = false;
|
||||
try {
|
||||
await loadLocalFeed(feedPath, {
|
||||
signaturePath: sigPath,
|
||||
publicKeyPem,
|
||||
verifyChecksumManifest: false,
|
||||
});
|
||||
} catch {
|
||||
didFail = true;
|
||||
}
|
||||
|
||||
if (didFail) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected failure for tampered feed, but it loaded");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: loadLocalFeed - missing signature fails (fail-closed)
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testLoadLocalFeed_MissingSignatureFails() {
|
||||
const testName = "loadLocalFeed: missing signature fails (fail-closed)";
|
||||
try {
|
||||
const { publicKeyPem } = generateEd25519KeyPair();
|
||||
const feedContent = createValidFeed();
|
||||
|
||||
const feedPath = path.join(tempDir, "nosig-feed.json");
|
||||
const sigPath = path.join(tempDir, "nosig-feed.json.sig");
|
||||
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
// Don't write signature file
|
||||
|
||||
let didFail = false;
|
||||
try {
|
||||
await loadLocalFeed(feedPath, {
|
||||
signaturePath: sigPath,
|
||||
publicKeyPem,
|
||||
verifyChecksumManifest: false,
|
||||
});
|
||||
} catch {
|
||||
didFail = true;
|
||||
}
|
||||
|
||||
if (didFail) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected failure for missing signature, but it loaded");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: loadLocalFeed - allowUnsigned bypasses verification
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testLoadLocalFeed_AllowUnsignedBypasses() {
|
||||
const testName = "loadLocalFeed: allowUnsigned=true bypasses verification";
|
||||
try {
|
||||
const feedContent = createValidFeed();
|
||||
|
||||
const feedPath = path.join(tempDir, "unsigned-feed.json");
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
|
||||
const feed = await loadLocalFeed(feedPath, {
|
||||
allowUnsigned: true,
|
||||
verifyChecksumManifest: false,
|
||||
});
|
||||
|
||||
if (feed && feed.version === "1.0.0") {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Feed did not load with allowUnsigned=true");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: loadLocalFeed - checksum mismatch fails
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testLoadLocalFeed_ChecksumMismatchFails() {
|
||||
const testName = "loadLocalFeed: checksum mismatch fails";
|
||||
try {
|
||||
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
|
||||
const feedContent = createValidFeed();
|
||||
const feedSignature = signPayload(feedContent, privateKeyPem);
|
||||
|
||||
// Create checksum manifest with WRONG hash
|
||||
const badChecksumManifest = JSON.stringify(
|
||||
{
|
||||
schema_version: "1.0",
|
||||
algorithm: "sha256",
|
||||
files: {
|
||||
"feed.json": "0".repeat(64), // Wrong hash
|
||||
"feed.json.sig":
|
||||
crypto.createHash("sha256").update(feedSignature + "\n").digest("hex"),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const checksumSignature = signPayload(badChecksumManifest, privateKeyPem);
|
||||
|
||||
const feedPath = path.join(tempDir, "badcs-feed.json");
|
||||
const sigPath = path.join(tempDir, "badcs-feed.json.sig");
|
||||
const checksumPath = path.join(tempDir, "badcs-checksums.json");
|
||||
const checksumSigPath = path.join(tempDir, "badcs-checksums.json.sig");
|
||||
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
await fs.writeFile(sigPath, feedSignature + "\n");
|
||||
await fs.writeFile(checksumPath, badChecksumManifest);
|
||||
await fs.writeFile(checksumSigPath, checksumSignature + "\n");
|
||||
|
||||
let didFail = false;
|
||||
try {
|
||||
await loadLocalFeed(feedPath, {
|
||||
signaturePath: sigPath,
|
||||
checksumsPath: checksumPath,
|
||||
checksumsSignaturePath: checksumSigPath,
|
||||
publicKeyPem,
|
||||
verifyChecksumManifest: true,
|
||||
});
|
||||
} catch {
|
||||
didFail = true;
|
||||
}
|
||||
|
||||
if (didFail) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected failure for checksum mismatch, but it loaded");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: isValidFeedPayload - valid feed
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testIsValidFeedPayload_Valid() {
|
||||
const testName = "isValidFeedPayload: valid feed passes";
|
||||
try {
|
||||
const feed = {
|
||||
version: "1.0.0",
|
||||
advisories: [
|
||||
{
|
||||
id: "TEST-001",
|
||||
severity: "high",
|
||||
affected: ["test-skill@1.0.0"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (isValidFeedPayload(feed)) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected valid feed to pass validation");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: isValidFeedPayload - missing version fails
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testIsValidFeedPayload_MissingVersion() {
|
||||
const testName = "isValidFeedPayload: missing version fails";
|
||||
try {
|
||||
const feed = {
|
||||
advisories: [
|
||||
{
|
||||
id: "TEST-001",
|
||||
severity: "high",
|
||||
affected: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (!isValidFeedPayload(feed)) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected feed without version to fail validation");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: isValidFeedPayload - advisory missing id fails
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testIsValidFeedPayload_AdvisoryMissingId() {
|
||||
const testName = "isValidFeedPayload: advisory missing id fails";
|
||||
try {
|
||||
const feed = {
|
||||
version: "1.0.0",
|
||||
advisories: [
|
||||
{
|
||||
severity: "high",
|
||||
affected: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (!isValidFeedPayload(feed)) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, "Expected advisory without id to fail validation");
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main test runner
|
||||
// -----------------------------------------------------------------------------
|
||||
async function runTests() {
|
||||
console.log("=== ClawSec Feed Verification Tests ===\n");
|
||||
|
||||
await setupTestDir();
|
||||
|
||||
try {
|
||||
// Signature verification tests
|
||||
await testVerifySignedPayload_ValidSignature();
|
||||
await testVerifySignedPayload_InvalidSignature();
|
||||
await testVerifySignedPayload_WrongKey();
|
||||
await testVerifySignedPayload_MalformedSignature();
|
||||
await testVerifySignedPayload_EmptySignature();
|
||||
await testVerifySignedPayload_JsonWrappedSignature();
|
||||
|
||||
// Local feed loading tests
|
||||
await testLoadLocalFeed_ValidSignedFeed();
|
||||
await testLoadLocalFeed_TamperedFeedFails();
|
||||
await testLoadLocalFeed_MissingSignatureFails();
|
||||
await testLoadLocalFeed_AllowUnsignedBypasses();
|
||||
await testLoadLocalFeed_ChecksumMismatchFails();
|
||||
|
||||
// Feed payload validation tests
|
||||
await testIsValidFeedPayload_Valid();
|
||||
await testIsValidFeedPayload_MissingVersion();
|
||||
await testIsValidFeedPayload_AdvisoryMissingId();
|
||||
} finally {
|
||||
await cleanupTestDir();
|
||||
}
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
console.error("Test runner failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,378 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Guarded skill install tests for clawsec-suite.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Conservative matching when version is omitted
|
||||
* - Precise version matching when version is provided
|
||||
* - Exit code 42 for advisory match requiring confirmation
|
||||
* - High-risk advisory detection
|
||||
*
|
||||
* Run: node skills/clawsec-suite/test/guarded_install.test.mjs
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "guarded_skill_install.mjs");
|
||||
|
||||
let tempDir;
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function pass(name) {
|
||||
passCount++;
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, error) {
|
||||
failCount++;
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(` ${String(error)}`);
|
||||
}
|
||||
|
||||
function generateEd25519KeyPair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
return { publicKeyPem, privateKeyPem };
|
||||
}
|
||||
|
||||
function signPayload(data, privateKeyPem) {
|
||||
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
|
||||
return signature.toString("base64");
|
||||
}
|
||||
|
||||
function createFeed(advisories) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
version: "1.0.0",
|
||||
updated: "2026-02-08T12:00:00Z",
|
||||
advisories,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function createChecksumManifest(files) {
|
||||
const checksums = {};
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
return JSON.stringify(
|
||||
{
|
||||
schema_version: "1.0",
|
||||
algorithm: "sha256",
|
||||
files: checksums,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
async function setupTestDir() {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-install-test-"));
|
||||
}
|
||||
|
||||
async function cleanupTestDir() {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function setupSignedFeed(advisories, keyPair) {
|
||||
const feedContent = createFeed(advisories);
|
||||
const feedSignature = signPayload(feedContent, keyPair.privateKeyPem);
|
||||
|
||||
const checksumManifest = createChecksumManifest({
|
||||
"feed.json": feedContent,
|
||||
"feed.json.sig": feedSignature + "\n",
|
||||
"feed-signing-public.pem": keyPair.publicKeyPem,
|
||||
});
|
||||
const checksumSignature = signPayload(checksumManifest, keyPair.privateKeyPem);
|
||||
|
||||
const advisoriesDir = path.join(tempDir, "advisories");
|
||||
await fs.mkdir(advisoriesDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(path.join(advisoriesDir, "feed.json"), feedContent);
|
||||
await fs.writeFile(path.join(advisoriesDir, "feed.json.sig"), feedSignature + "\n");
|
||||
await fs.writeFile(path.join(advisoriesDir, "checksums.json"), checksumManifest);
|
||||
await fs.writeFile(path.join(advisoriesDir, "checksums.json.sig"), checksumSignature + "\n");
|
||||
await fs.writeFile(path.join(advisoriesDir, "feed-signing-public.pem"), keyPair.publicKeyPem);
|
||||
|
||||
return advisoriesDir;
|
||||
}
|
||||
|
||||
function runGuardedInstall(args, env) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("node", [SCRIPT_PATH, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Conservative matching when version is omitted
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testConservativeMatchingWithoutVersion() {
|
||||
const testName = "guarded_install: conservative matching without version triggers advisory";
|
||||
try {
|
||||
const keyPair = generateEd25519KeyPair();
|
||||
const advisoriesDir = await setupSignedFeed(
|
||||
[
|
||||
{
|
||||
id: "TEST-001",
|
||||
severity: "high",
|
||||
affected: ["test-skill@1.0.0", "test-skill@1.0.1"],
|
||||
},
|
||||
],
|
||||
keyPair,
|
||||
);
|
||||
|
||||
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
||||
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
||||
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
||||
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
||||
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
||||
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
||||
CLAWSEC_FEED_URL: "file:///nonexistent", // Force local fallback
|
||||
});
|
||||
|
||||
if (result.code === 42 && result.stdout.includes("Conservative")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 42 with conservative message, got ${result.code}: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Precise version matching
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testPreciseVersionMatching() {
|
||||
const testName = "guarded_install: precise version matching only matches exact version";
|
||||
try {
|
||||
const keyPair = generateEd25519KeyPair();
|
||||
const advisoriesDir = await setupSignedFeed(
|
||||
[
|
||||
{
|
||||
id: "TEST-001",
|
||||
severity: "high",
|
||||
affected: ["test-skill@1.0.0"],
|
||||
},
|
||||
],
|
||||
keyPair,
|
||||
);
|
||||
|
||||
// Version 2.0.0 should NOT match advisory for 1.0.0
|
||||
const result = await runGuardedInstall(
|
||||
["--skill", "test-skill", "--version", "2.0.0", "--dry-run"],
|
||||
{
|
||||
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
||||
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
||||
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
||||
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
||||
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
||||
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||
},
|
||||
);
|
||||
|
||||
if (result.code === 0 && !result.stdout.includes("Advisory matches")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 0 without match, got ${result.code}: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Version match triggers confirmation requirement
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testVersionMatchTriggersConfirmation() {
|
||||
const testName = "guarded_install: matching version triggers exit 42";
|
||||
try {
|
||||
const keyPair = generateEd25519KeyPair();
|
||||
const advisoriesDir = await setupSignedFeed(
|
||||
[
|
||||
{
|
||||
id: "TEST-001",
|
||||
severity: "high",
|
||||
affected: ["test-skill@1.0.0"],
|
||||
},
|
||||
],
|
||||
keyPair,
|
||||
);
|
||||
|
||||
const result = await runGuardedInstall(
|
||||
["--skill", "test-skill", "--version", "1.0.0", "--dry-run"],
|
||||
{
|
||||
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
||||
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
||||
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
||||
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
||||
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
||||
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||
},
|
||||
);
|
||||
|
||||
if (result.code === 42 && result.stdout.includes("Advisory matches")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 42 with advisory match, got ${result.code}: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: --confirm-advisory allows proceeding
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testConfirmAdvisoryAllowsProceeding() {
|
||||
const testName = "guarded_install: --confirm-advisory with --dry-run proceeds";
|
||||
try {
|
||||
const keyPair = generateEd25519KeyPair();
|
||||
const advisoriesDir = await setupSignedFeed(
|
||||
[
|
||||
{
|
||||
id: "TEST-001",
|
||||
severity: "high",
|
||||
affected: ["test-skill@1.0.0"],
|
||||
},
|
||||
],
|
||||
keyPair,
|
||||
);
|
||||
|
||||
const result = await runGuardedInstall(
|
||||
["--skill", "test-skill", "--version", "1.0.0", "--confirm-advisory", "--dry-run"],
|
||||
{
|
||||
CLAWSEC_LOCAL_FEED: path.join(advisoriesDir, "feed.json"),
|
||||
CLAWSEC_LOCAL_FEED_SIG: path.join(advisoriesDir, "feed.json.sig"),
|
||||
CLAWSEC_LOCAL_FEED_CHECKSUMS: path.join(advisoriesDir, "checksums.json"),
|
||||
CLAWSEC_LOCAL_FEED_CHECKSUMS_SIG: path.join(advisoriesDir, "checksums.json.sig"),
|
||||
CLAWSEC_FEED_PUBLIC_KEY: path.join(advisoriesDir, "feed-signing-public.pem"),
|
||||
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||
},
|
||||
);
|
||||
|
||||
if (result.code === 0 && result.stdout.includes("Dry run")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 0 with dry run message, got ${result.code}: ${result.stdout}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: allowUnsigned bypass warning
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testAllowUnsignedWarning() {
|
||||
const testName = "guarded_install: CLAWSEC_ALLOW_UNSIGNED_FEED shows warning";
|
||||
try {
|
||||
// Create unsigned feed (no signatures)
|
||||
const feedContent = createFeed([]);
|
||||
const feedPath = path.join(tempDir, "unsigned-feed.json");
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
|
||||
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
||||
CLAWSEC_LOCAL_FEED: feedPath,
|
||||
CLAWSEC_ALLOW_UNSIGNED_FEED: "1",
|
||||
CLAWSEC_VERIFY_CHECKSUM_MANIFEST: "0",
|
||||
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||
});
|
||||
|
||||
if (result.stderr.includes("CLAWSEC_ALLOW_UNSIGNED_FEED")) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected unsigned mode warning, got: ${result.stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test: Missing signature fails without allowUnsigned
|
||||
// -----------------------------------------------------------------------------
|
||||
async function testMissingSignatureFails() {
|
||||
const testName = "guarded_install: missing signature fails without allowUnsigned";
|
||||
try {
|
||||
const feedContent = createFeed([]);
|
||||
const feedPath = path.join(tempDir, "nosig-feed.json");
|
||||
await fs.writeFile(feedPath, feedContent);
|
||||
|
||||
const result = await runGuardedInstall(["--skill", "test-skill", "--dry-run"], {
|
||||
CLAWSEC_LOCAL_FEED: feedPath,
|
||||
CLAWSEC_FEED_URL: "file:///nonexistent",
|
||||
});
|
||||
|
||||
if (result.code === 1) {
|
||||
pass(testName);
|
||||
} else {
|
||||
fail(testName, `Expected exit 1 for missing signature, got ${result.code}`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(testName, error);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Main test runner
|
||||
// -----------------------------------------------------------------------------
|
||||
async function runTests() {
|
||||
console.log("=== ClawSec Guarded Install Tests ===\n");
|
||||
|
||||
await setupTestDir();
|
||||
|
||||
try {
|
||||
await testConservativeMatchingWithoutVersion();
|
||||
await testPreciseVersionMatching();
|
||||
await testVersionMatchTriggersConfirmation();
|
||||
await testConfirmAdvisoryAllowsProceeding();
|
||||
await testAllowUnsignedWarning();
|
||||
await testMissingSignatureFails();
|
||||
} finally {
|
||||
await cleanupTestDir();
|
||||
}
|
||||
|
||||
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
console.error("Test runner failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user