mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-01 15:52:26 +03:00
feat(advisories): add provisional GHSA feed (#242)
* feat(advisories): add provisional ghsa feed * fix(workflows): include advisory signatures in checksums * fix(workflows): mirror ghsa feed at release root * feat(advisories): consolidate ghsa into agent feed * ci(advisories): consolidate ghsa during nvd poll * fix(advisories): retain unreplaced ghsa feed entries * chore(skills): bump advisory feed consumers * fix(release): resolve ts import closure dry run * fix(release): preserve urls while stripping comments * fix(release): ignore skill test-only changes * fix(advisories): follow ghsa pagination links * test(advisories): add nvd ghsa pipeline dry run
This commit is contained in:
@@ -93,6 +93,29 @@ jobs:
|
||||
- name: Check for outdated deps
|
||||
run: npm outdated || true
|
||||
|
||||
advisory-feed-tests:
|
||||
name: Advisory Feed Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: GHSA Without CVE Feed Tests
|
||||
run: node scripts/test-ghsa-without-cve-feed.mjs
|
||||
- name: GHSA Poll Workflow Tests
|
||||
run: node scripts/test-ghsa-poll-workflow.mjs
|
||||
- name: NVD GHSA Consolidation Workflow Tests
|
||||
run: node scripts/test-nvd-ghsa-consolidation-workflow.mjs
|
||||
- name: NVD + GHSA Pipeline Dry Run
|
||||
run: node scripts/test-nvd-ghsa-pipeline-dry-run.mjs
|
||||
- name: Skill Release Workflow Tests
|
||||
run: node scripts/test-skill-release-workflow.mjs
|
||||
- name: Deploy Pages Advisory Checksums Tests
|
||||
run: node scripts/test-deploy-pages-checksums.mjs
|
||||
|
||||
clawsec-suite-tests:
|
||||
name: ClawSec Suite Verification Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -249,16 +249,51 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p public/advisories
|
||||
cp advisories/feed.json public/advisories/feed.json
|
||||
if [ -f advisories/ghsa-without-cve.json ]; then
|
||||
cp advisories/ghsa-without-cve.json public/advisories/ghsa-without-cve.json
|
||||
fi
|
||||
echo "Copied advisory feed to public/advisories/"
|
||||
cat public/advisories/feed.json | jq '.advisories | length' | xargs -I {} echo "Feed contains {} advisories"
|
||||
if [ -f public/advisories/ghsa-without-cve.json ]; then
|
||||
cat public/advisories/ghsa-without-cve.json | jq '.advisories | length' | xargs -I {} echo "GHSA provisional feed contains {} advisories"
|
||||
fi
|
||||
|
||||
- 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 provisional GHSA feed and verify
|
||||
if: hashFiles('public/advisories/ghsa-without-cve.json') != ''
|
||||
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/ghsa-without-cve.json
|
||||
signature_file: public/advisories/ghsa-without-cve.json.sig
|
||||
|
||||
- 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")
|
||||
FILES_JSON="{}"
|
||||
ADVISORY_ARTIFACTS=(public/advisories/*.json public/advisories/*.json.sig)
|
||||
for file in "${ADVISORY_ARTIFACTS[@]}"; do
|
||||
[ -e "$file" ] || continue
|
||||
REL_PATH="${file#public/}"
|
||||
FILE_SHA=$(sha256sum "$file" | awk '{print $1}')
|
||||
FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
FILES_JSON=$(jq \
|
||||
--arg path "$REL_PATH" \
|
||||
--arg sha "$FILE_SHA" \
|
||||
--argjson size "$FILE_SIZE" \
|
||||
'. + {($path): {sha256: $sha, size: $size, path: $path, url: ("https://clawsec.prompt.security/" + $path)}}' \
|
||||
<<< "$FILES_JSON")
|
||||
done
|
||||
|
||||
# Generate checksums manifest conforming to parseChecksumsManifest expectations:
|
||||
# - schema_version: "1" (manifest format version)
|
||||
@@ -272,36 +307,19 @@ jobs:
|
||||
--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" \
|
||||
--argjson files "$FILES_JSON" \
|
||||
'{
|
||||
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"
|
||||
}
|
||||
}
|
||||
files: $files
|
||||
}' > 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:
|
||||
@@ -334,7 +352,7 @@ jobs:
|
||||
- name: Show signed advisory artifacts
|
||||
run: |
|
||||
echo "Signed advisory artifacts:"
|
||||
ls -la public/advisories/feed.json*
|
||||
ls -la public/advisories/*.json*
|
||||
ls -la public/checksums.json public/checksums.sig public/signing-public.pem
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -387,6 +405,16 @@ jobs:
|
||||
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/advisories/ghsa-without-cve.json" ]; then
|
||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||
cp "public/advisories/ghsa-without-cve.json" "$MIRROR_LATEST_DIR/advisories/ghsa-without-cve.json"
|
||||
cp "public/advisories/ghsa-without-cve.json" "$MIRROR_LATEST_DIR/ghsa-without-cve.json"
|
||||
fi
|
||||
if [ -f "public/advisories/ghsa-without-cve.json.sig" ]; then
|
||||
mkdir -p "$MIRROR_LATEST_DIR/advisories"
|
||||
cp "public/advisories/ghsa-without-cve.json.sig" "$MIRROR_LATEST_DIR/advisories/ghsa-without-cve.json.sig"
|
||||
cp "public/advisories/ghsa-without-cve.json.sig" "$MIRROR_LATEST_DIR/ghsa-without-cve.json.sig"
|
||||
fi
|
||||
if [ -f "public/checksums.json" ]; then
|
||||
cp "public/checksums.json" "$MIRROR_LATEST_DIR/checksums.json"
|
||||
fi
|
||||
|
||||
@@ -27,14 +27,26 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p public/advisories
|
||||
cp advisories/feed.json public/advisories/feed.json
|
||||
if [ -f advisories/ghsa-without-cve.json ]; then
|
||||
cp advisories/ghsa-without-cve.json public/advisories/ghsa-without-cve.json
|
||||
fi
|
||||
|
||||
- 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")
|
||||
FILES_JSON="{}"
|
||||
for file in public/advisories/*.json; do
|
||||
REL_PATH="${file#public/}"
|
||||
FILE_SHA=$(sha256sum "$file" | awk '{print $1}')
|
||||
FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
FILES_JSON=$(jq \
|
||||
--arg path "$REL_PATH" \
|
||||
--arg sha "$FILE_SHA" \
|
||||
--argjson size "$FILE_SIZE" \
|
||||
'. + {($path): {sha256: $sha, size: $size, path: $path, url: ("https://clawsec.prompt.security/" + $path)}}' \
|
||||
<<< "$FILES_JSON")
|
||||
done
|
||||
|
||||
jq -n \
|
||||
--arg schema_version "1" \
|
||||
@@ -42,22 +54,14 @@ jobs:
|
||||
--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" \
|
||||
--argjson files "$FILES_JSON" \
|
||||
'{
|
||||
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"
|
||||
}
|
||||
}
|
||||
files: $files
|
||||
}' > public/checksums.json
|
||||
|
||||
- name: Generate ephemeral signing key for PR verification
|
||||
@@ -81,6 +85,14 @@ jobs:
|
||||
signature_file: public/advisories/feed.json.sig
|
||||
public_key_output: public/signing-public.pem
|
||||
|
||||
- name: Sign provisional GHSA feed and verify
|
||||
if: hashFiles('public/advisories/ghsa-without-cve.json') != ''
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ steps.test_key.outputs.private_key }}
|
||||
input_file: public/advisories/ghsa-without-cve.json
|
||||
signature_file: public/advisories/ghsa-without-cve.json.sig
|
||||
|
||||
- name: Sign checksums and verify
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
@@ -107,5 +119,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
test -f dist/index.html
|
||||
test -f public/advisories/feed.json.sig
|
||||
if [ -f public/advisories/ghsa-without-cve.json ]; then
|
||||
test -f public/advisories/ghsa-without-cve.json.sig
|
||||
fi
|
||||
test -f public/checksums.sig
|
||||
test -f public/signing-public.pem
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
name: Poll GHSA Without CVE
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
concurrency:
|
||||
group: poll-ghsa-without-cve
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FEED_PATH: advisories/feed.json
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
GHSA_FEED_PATH: advisories/ghsa-without-cve.json
|
||||
GHSA_FEED_SIG_PATH: advisories/ghsa-without-cve.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
|
||||
jobs:
|
||||
poll-and-update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run GHSA feed tests
|
||||
run: node scripts/test-ghsa-without-cve-feed.mjs
|
||||
|
||||
- name: Poll GitHub Security Advisories
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/ghsa-without-cve-feed.mjs \
|
||||
--output "$GHSA_FEED_PATH" \
|
||||
--consolidated-feed "$FEED_PATH" \
|
||||
--existing-feed "$GHSA_FEED_PATH" \
|
||||
--nvd-feed "$FEED_PATH" \
|
||||
--stale-after-days 60
|
||||
|
||||
- name: Sync consolidated feed to clawsec-feed skill
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
|
||||
cp "$FEED_PATH" "$SKILL_FEED_PATH"
|
||||
|
||||
- name: Detect feed changes
|
||||
id: changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
GHSA_CHANGED=false
|
||||
AGENT_CHANGED=false
|
||||
|
||||
if ! git diff --quiet -- "$GHSA_FEED_PATH" || [ ! -f "$GHSA_FEED_SIG_PATH" ]; then
|
||||
GHSA_CHANGED=true
|
||||
fi
|
||||
|
||||
if ! git diff --quiet -- "$FEED_PATH" "$SKILL_FEED_PATH" || [ ! -f "$FEED_SIG_PATH" ] || [ ! -f "$SKILL_FEED_SIG_PATH" ]; then
|
||||
AGENT_CHANGED=true
|
||||
fi
|
||||
|
||||
echo "ghsa_changed=$GHSA_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "agent_changed=$AGENT_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$GHSA_CHANGED" = "true" ] || [ "$AGENT_CHANGED" = "true" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Sign GHSA feed and verify
|
||||
if: steps.changes.outputs.ghsa_changed == '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.GHSA_FEED_PATH }}
|
||||
signature_file: ${{ env.GHSA_FEED_SIG_PATH }}
|
||||
|
||||
- name: Sign consolidated agent feed and verify
|
||||
if: steps.changes.outputs.agent_changed == '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 consolidated signature to clawsec-feed skill
|
||||
if: steps.changes.outputs.agent_changed == 'true'
|
||||
run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
branch: automated/ghsa-without-cve-feed
|
||||
delete-branch: true
|
||||
title: 'chore: update provisional GHSA advisory feed'
|
||||
body: |
|
||||
## Summary
|
||||
Updates the provisional GHSA advisory feed and the consolidated agent advisory feed.
|
||||
|
||||
- Feed: `${{ env.GHSA_FEED_PATH }}`
|
||||
- Agent feed: `${{ env.FEED_PATH }}`
|
||||
- Stale threshold: 60 days without a CVE
|
||||
- Statuses: `active`, `matured`, `stale`
|
||||
|
||||
---
|
||||
*This PR was automatically generated by the GHSA-without-CVE polling workflow.*
|
||||
commit-message: |
|
||||
chore: update provisional GHSA advisory feed
|
||||
|
||||
Poll public GitHub Security Advisories without CVE identifiers.
|
||||
add-paths: |
|
||||
${{ env.FEED_PATH }}
|
||||
${{ env.FEED_SIG_PATH }}
|
||||
${{ env.GHSA_FEED_PATH }}
|
||||
${{ env.GHSA_FEED_SIG_PATH }}
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
${{ env.SKILL_FEED_SIG_PATH }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "## GHSA Without CVE Poll Summary" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Feed changed | ${{ steps.changes.outputs.changed }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Agent feed changed | ${{ steps.changes.outputs.agent_changed }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| GHSA source feed changed | ${{ steps.changes.outputs.ghsa_changed }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Feed path | $GHSA_FEED_PATH |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Agent feed path | $FEED_PATH |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Total advisories | $(jq '.advisories | length' "$GHSA_FEED_PATH") |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Active | $(jq '[.advisories[] | select(.status == "active")] | length' "$GHSA_FEED_PATH") |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Matured | $(jq '[.advisories[] | select(.status == "matured")] | length' "$GHSA_FEED_PATH") |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Stale | $(jq '[.advisories[] | select(.status == "stale")] | length' "$GHSA_FEED_PATH") |" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "${{ steps.create-pr.outputs.pull-request-url }}" ]; then
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Upserted PR: ${{ steps.create-pr.outputs.pull-request-url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -21,6 +21,8 @@ concurrency:
|
||||
env:
|
||||
FEED_PATH: advisories/feed.json
|
||||
FEED_SIG_PATH: advisories/feed.json.sig
|
||||
GHSA_FEED_PATH: advisories/ghsa-without-cve.json
|
||||
GHSA_FEED_SIG_PATH: advisories/ghsa-without-cve.json.sig
|
||||
SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
|
||||
SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig
|
||||
|
||||
@@ -833,8 +835,54 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Poll GHSA without CVE and consolidate feed
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/ghsa-without-cve-feed.mjs \
|
||||
--output "$GHSA_FEED_PATH" \
|
||||
--consolidated-feed "$FEED_PATH" \
|
||||
--existing-feed "$GHSA_FEED_PATH" \
|
||||
--nvd-feed "$FEED_PATH" \
|
||||
--stale-after-days 60
|
||||
|
||||
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
|
||||
cp "$FEED_PATH" "$SKILL_FEED_PATH"
|
||||
|
||||
- name: Detect advisory feed changes
|
||||
id: feed_changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
NVD_CHANGED=false
|
||||
GHSA_CHANGED=false
|
||||
AGENT_CHANGED=false
|
||||
|
||||
if [ "${{ steps.transform.outputs.new_count }}" != "0" ] || [ "${{ steps.updates.outputs.update_count }}" != "0" ]; then
|
||||
NVD_CHANGED=true
|
||||
fi
|
||||
|
||||
if ! git diff --quiet -- "$GHSA_FEED_PATH" || [ ! -f "$GHSA_FEED_SIG_PATH" ]; then
|
||||
GHSA_CHANGED=true
|
||||
fi
|
||||
|
||||
if ! git diff --quiet -- "$FEED_PATH" "$SKILL_FEED_PATH" || [ ! -f "$FEED_SIG_PATH" ] || [ ! -f "$SKILL_FEED_SIG_PATH" ]; then
|
||||
AGENT_CHANGED=true
|
||||
fi
|
||||
|
||||
echo "nvd_changed=$NVD_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "ghsa_changed=$GHSA_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "agent_changed=$AGENT_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$GHSA_CHANGED" = "true" ] || [ "$AGENT_CHANGED" = "true" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Guard dependency manifests from NVD updates
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
if: steps.feed_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -851,8 +899,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign GHSA feed and verify
|
||||
if: steps.feed_changes.outputs.ghsa_changed == '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.GHSA_FEED_PATH }}
|
||||
signature_file: ${{ env.GHSA_FEED_SIG_PATH }}
|
||||
|
||||
- name: Sign advisory feed and verify
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
if: steps.feed_changes.outputs.agent_changed == 'true'
|
||||
uses: ./.github/actions/sign-and-verify
|
||||
with:
|
||||
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
|
||||
@@ -864,18 +921,18 @@ jobs:
|
||||
${{ env.SKILL_FEED_PATH }}
|
||||
|
||||
- name: Sync advisory signature to skill feed
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
if: steps.feed_changes.outputs.agent_changed == 'true'
|
||||
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'
|
||||
if: steps.feed_changes.outputs.changed == 'true'
|
||||
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: Upsert NVD advisory PR
|
||||
if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
|
||||
if: steps.feed_changes.outputs.changed == 'true'
|
||||
id: upsert-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -884,9 +941,14 @@ jobs:
|
||||
|
||||
BRANCH_PREFIX="automated/nvd-cve-update"
|
||||
PR_COMMENT="Superseded by newer automated NVD advisory update."
|
||||
TITLE="chore: CVE advisories - ${{ steps.transform.outputs.new_count }} new, ${{ steps.updates.outputs.update_count }} updated"
|
||||
TITLE="chore: update NVD/GHSA advisories - ${{ steps.transform.outputs.new_count }} NVD new, ${{ steps.updates.outputs.update_count }} NVD updated"
|
||||
COMMIT_SUBJECT="$TITLE"
|
||||
COMMIT_BODY=$'Automated update from NVD CVE feed.\nKeywords: ${{ env.KEYWORDS }}\nPoll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}'
|
||||
COMMIT_BODY=$'Automated update from NVD CVE and GHSA advisory feeds.\nKeywords: ${{ env.KEYWORDS }}\nPoll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}'
|
||||
|
||||
GHSA_TOTAL="$(jq '.advisories | length' "$GHSA_FEED_PATH")"
|
||||
GHSA_ACTIVE="$(jq '[.advisories[] | select(.status == "active")] | length' "$GHSA_FEED_PATH")"
|
||||
GHSA_MATURED="$(jq '[.advisories[] | select(.status == "matured")] | length' "$GHSA_FEED_PATH")"
|
||||
GHSA_STALE="$(jq '[.advisories[] | select(.status == "stale")] | length' "$GHSA_FEED_PATH")"
|
||||
|
||||
if [ "${{ inputs.force_full_scan }}" = "true" ]; then
|
||||
MODE="full-rebuild (ignore feed state)"
|
||||
@@ -897,16 +959,19 @@ jobs:
|
||||
BODY_FILE="$(mktemp)"
|
||||
cat > "$BODY_FILE" <<EOF
|
||||
## Summary
|
||||
Automated update from NVD CVE feed.
|
||||
Automated update from NVD CVE and GHSA advisory feeds.
|
||||
|
||||
- **Mode:** ${MODE}
|
||||
- **New advisories:** ${{ steps.transform.outputs.new_count }}
|
||||
- **Updated advisories:** ${{ steps.updates.outputs.update_count }}
|
||||
- **New NVD advisories:** ${{ steps.transform.outputs.new_count }}
|
||||
- **Updated NVD advisories:** ${{ steps.updates.outputs.update_count }}
|
||||
- **GHSA source feed changed:** ${{ steps.feed_changes.outputs.ghsa_changed }}
|
||||
- **Consolidated agent feed changed:** ${{ steps.feed_changes.outputs.agent_changed }}
|
||||
- **GHSA provisional advisories:** ${GHSA_TOTAL} total (${GHSA_ACTIVE} active, ${GHSA_MATURED} matured, ${GHSA_STALE} stale)
|
||||
- **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.*
|
||||
*This PR was automatically generated by the NVD CVE polling workflow with GHSA consolidation.*
|
||||
EOF
|
||||
|
||||
PR_LIST_JSON="$(
|
||||
@@ -953,7 +1018,7 @@ jobs:
|
||||
git fetch origin main
|
||||
git checkout -B "$TARGET_BRANCH" origin/main
|
||||
|
||||
git add "$FEED_PATH" "$FEED_SIG_PATH" "$SKILL_FEED_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
git add "$FEED_PATH" "$FEED_SIG_PATH" "$GHSA_FEED_PATH" "$GHSA_FEED_SIG_PATH" "$SKILL_FEED_PATH" "$SKILL_FEED_SIG_PATH"
|
||||
if git diff --cached --quiet; then
|
||||
echo "::error::Expected advisory feed changes but none were staged."
|
||||
exit 1
|
||||
@@ -1033,6 +1098,9 @@ jobs:
|
||||
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
|
||||
echo "| GHSA source feed changed | ${{ steps.feed_changes.outputs.ghsa_changed }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Consolidated agent feed changed | ${{ steps.feed_changes.outputs.agent_changed }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| GHSA provisional advisories | $(jq '.advisories | length' "$GHSA_FEED_PATH") |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "${{ steps.transform.outputs.new_count }}" != "0" ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -6,8 +6,7 @@ on:
|
||||
- '*-v[0-9]*.[0-9]*.[0-9]*'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'skills/*/skill.json'
|
||||
- 'skills/*/SKILL.md'
|
||||
- 'skills/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -39,7 +38,7 @@ jobs:
|
||||
- name: Verify signing key consistency (repo + docs)
|
||||
run: ./scripts/ci/verify_signing_key_consistency.sh
|
||||
|
||||
- name: Validate version parity for bumped skills
|
||||
- name: Validate version parity for changed skills
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -79,12 +78,15 @@ jobs:
|
||||
}
|
||||
|
||||
touched_skills_file="$(mktemp)"
|
||||
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- 'skills/*/skill.json' 'skills/*/SKILL.md' \
|
||||
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- \
|
||||
'skills/*/**' \
|
||||
':(exclude)skills/*/test/**' \
|
||||
':(exclude)skills/*/tests/**' \
|
||||
| awk -F/ 'NF >= 3 {print $1 "/" $2}' \
|
||||
| sort -u > "${touched_skills_file}"
|
||||
|
||||
if [ ! -s "${touched_skills_file}" ]; then
|
||||
echo "No skill metadata files changed in this PR."
|
||||
echo "No release-relevant skill package files changed in this PR."
|
||||
rm -f "${touched_skills_file}"
|
||||
exit 0
|
||||
fi
|
||||
@@ -129,6 +131,8 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
checked_skills=$((checked_skills + 1))
|
||||
|
||||
json_version_changed=false
|
||||
md_version_changed=false
|
||||
|
||||
@@ -141,11 +145,11 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ "${json_version_changed}" != "true" ] && [ "${md_version_changed}" != "true" ]; then
|
||||
echo "No version bump detected for ${skill_dir}; skipping."
|
||||
echo "::error file=${skill_dir}::Changed skill package has no version bump. Update skill.json and SKILL.md versions and add CHANGELOG.md release notes."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
checked_skills=$((checked_skills + 1))
|
||||
echo "Version bump detected for ${skill_dir} (skill.json changed: ${json_version_changed}, SKILL.md changed: ${md_version_changed})"
|
||||
|
||||
if [ ! -f "${json_path}" ]; then
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"updated": "2026-05-24T07:39:08Z",
|
||||
"description": "Provisional ClawSec advisory feed for public GitHub Security Advisories that do not yet have CVE identifiers.",
|
||||
"stale_after_days": 60,
|
||||
"semantics": {
|
||||
"active": "GHSA is published and has no CVE identifier yet.",
|
||||
"matured": "GHSA now has a CVE identifier and should be reconciled with the canonical CVE feed.",
|
||||
"stale": "GHSA is older than stale_after_days and still has no CVE identifier."
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"repository": "openclaw/openclaw",
|
||||
"platform": "openclaw",
|
||||
"url": "https://github.com/openclaw/openclaw/security/advisories"
|
||||
},
|
||||
{
|
||||
"repository": "qwibitai/nanoclaw",
|
||||
"platform": "nanoclaw",
|
||||
"url": "https://github.com/qwibitai/nanoclaw/security/advisories"
|
||||
},
|
||||
{
|
||||
"repository": "softwarepub/hermes",
|
||||
"platform": "hermes",
|
||||
"url": "https://github.com/softwarepub/hermes/security/advisories"
|
||||
},
|
||||
{
|
||||
"repository": "nousresearch/hermes-agent",
|
||||
"platform": "hermes",
|
||||
"url": "https://github.com/nousresearch/hermes-agent/security/advisories"
|
||||
},
|
||||
{
|
||||
"repository": "sipeed/picoclaw",
|
||||
"platform": "picoclaw",
|
||||
"url": "https://github.com/sipeed/picoclaw/security/advisories"
|
||||
}
|
||||
],
|
||||
"advisories": []
|
||||
}
|
||||
@@ -46,6 +46,66 @@ class VerifySkillReleaseImportClosureTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_ts_source_accepts_js_import_specifier(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
(root / "types.ts").write_text("export type Value = string;\n", encoding="utf-8")
|
||||
(root / "main.ts").write_text("import type { Value } from './types.js';\n", encoding="utf-8")
|
||||
|
||||
failures = self.module.verify_import_closure(root)
|
||||
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_comment_import_examples_are_ignored(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
(root / "main.ts").write_text(
|
||||
"/*\n"
|
||||
" * Example integration:\n"
|
||||
" * import { Missing } from '../external/project/file';\n"
|
||||
" */\n"
|
||||
"export {};\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
failures = self.module.verify_import_closure(root)
|
||||
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_url_string_does_not_hide_following_relative_import(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
(root / "main.ts").write_text(
|
||||
'const feedUrl = "https://example.test/feed.json"; import value from "./missing.js";\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
failures = self.module.verify_import_closure(root)
|
||||
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("main.ts imports ./missing.js", failures[0])
|
||||
|
||||
def test_remote_import_spec_survives_comment_stripping(self) -> None:
|
||||
source = 'import remote from "https://example.test/module.mjs";\n'
|
||||
stripped = self.module.strip_js_ts_comments(source)
|
||||
|
||||
specs = [match.group("spec") for match in self.module.IMPORT_RE.finditer(stripped)]
|
||||
|
||||
self.assertEqual(specs, ["https://example.test/module.mjs"])
|
||||
|
||||
def test_remote_runtime_import_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
(root / "main.mjs").write_text(
|
||||
'import remote from "https://example.test/module.mjs";\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
failures = self.module.verify_import_closure(root)
|
||||
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("remote runtime import https://example.test/module.mjs", failures[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify staged skill release JS/TS relative imports are self-contained.
|
||||
"""Verify staged skill release JS/TS imports are self-contained.
|
||||
|
||||
The skill release workflow builds archives from `skill.json.sbom.files`. If a
|
||||
runtime helper exists in the repo but is omitted from the SBOM, the staged
|
||||
release can contain files whose relative imports point at missing files. This
|
||||
script checks the staged payload, not the source tree, so it catches exactly
|
||||
what would ship.
|
||||
release can contain files whose relative imports point at missing files or
|
||||
remote runtime imports. This script checks the staged payload, not the source
|
||||
tree, so it catches exactly what would ship.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,18 +22,88 @@ IMPORT_RE = re.compile(
|
||||
r"|\bimport\s*\(\s*"
|
||||
r"|\brequire\s*\(\s*"
|
||||
r")"
|
||||
r"['\"](?P<spec>\.{1,2}/[^'\"]+)['\"]",
|
||||
r"['\"](?P<spec>(?:\.{1,2}/|https?://)[^'\"]+)['\"]",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
SOURCE_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"}
|
||||
RESOLUTION_SUFFIXES = ["", ".mjs", ".js", ".cjs", ".mts", ".ts", ".cts", ".json"]
|
||||
INDEX_FILENAMES = ["index.mjs", "index.js", "index.cjs", "index.mts", "index.ts", "index.cts", "index.json"]
|
||||
TS_IMPORTER_SUFFIXES = {".ts", ".mts", ".cts"}
|
||||
JS_TO_TS_SUFFIX = {".js": ".ts", ".mjs": ".mts", ".cjs": ".cts"}
|
||||
|
||||
|
||||
def strip_js_ts_comments(text: str) -> str:
|
||||
stripped: list[str] = []
|
||||
state = "code"
|
||||
i = 0
|
||||
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
next_char = text[i + 1] if i + 1 < len(text) else ""
|
||||
|
||||
if state == "line_comment":
|
||||
if char in "\r\n":
|
||||
stripped.append(char)
|
||||
state = "code"
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if state == "block_comment":
|
||||
if char == "*" and next_char == "/":
|
||||
state = "code"
|
||||
i += 2
|
||||
continue
|
||||
if char in "\r\n":
|
||||
stripped.append(char)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if state in {"single", "double", "template"}:
|
||||
stripped.append(char)
|
||||
if char == "\\" and i + 1 < len(text):
|
||||
stripped.append(text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if (state == "single" and char == "'") or (state == "double" and char == '"') or (
|
||||
state == "template" and char == "`"
|
||||
):
|
||||
state = "code"
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if char == "/" and next_char == "/":
|
||||
stripped.append(" ")
|
||||
state = "line_comment"
|
||||
i += 2
|
||||
continue
|
||||
if char == "/" and next_char == "*":
|
||||
stripped.append(" ")
|
||||
state = "block_comment"
|
||||
i += 2
|
||||
continue
|
||||
|
||||
stripped.append(char)
|
||||
if char == "'":
|
||||
state = "single"
|
||||
elif char == '"':
|
||||
state = "double"
|
||||
elif char == "`":
|
||||
state = "template"
|
||||
i += 1
|
||||
|
||||
return "".join(stripped)
|
||||
|
||||
|
||||
def is_remote_spec(spec: str) -> bool:
|
||||
return spec.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def candidate_paths(importer: Path, spec: str) -> list[Path]:
|
||||
base = (importer.parent / spec).resolve()
|
||||
candidates = [base]
|
||||
if importer.suffix in TS_IMPORTER_SUFFIXES and base.suffix in JS_TO_TS_SUFFIX:
|
||||
candidates.append(base.with_suffix(JS_TO_TS_SUFFIX[base.suffix]))
|
||||
candidates.extend(base.with_suffix(suffix) for suffix in RESOLUTION_SUFFIXES if suffix and base.suffix == "")
|
||||
candidates.extend(base / name for name in INDEX_FILENAMES)
|
||||
return candidates
|
||||
@@ -57,13 +127,18 @@ def verify_import_closure(root: Path) -> list[str]:
|
||||
|
||||
for source in sorted(p for p in root.rglob("*") if p.is_file() and p.suffix in SOURCE_SUFFIXES):
|
||||
text = source.read_text(encoding="utf-8", errors="ignore")
|
||||
text = strip_js_ts_comments(text)
|
||||
for match in IMPORT_RE.finditer(text):
|
||||
spec = match.group("spec")
|
||||
rel_source = source.relative_to(root).as_posix()
|
||||
if is_remote_spec(spec):
|
||||
failures.append(f"{rel_source} imports remote runtime import {spec}")
|
||||
continue
|
||||
|
||||
candidates = candidate_paths(source, spec)
|
||||
if any(is_resolved_file(candidate, root) for candidate in candidates):
|
||||
continue
|
||||
|
||||
rel_source = source.relative_to(root).as_posix()
|
||||
display_target = (source.parent / spec).resolve()
|
||||
try:
|
||||
rel_target = display_target.relative_to(root).as_posix()
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const DEFAULT_REPOSITORIES = [
|
||||
'openclaw/openclaw',
|
||||
'qwibitai/nanoclaw',
|
||||
'softwarepub/hermes',
|
||||
'nousresearch/hermes-agent',
|
||||
'sipeed/picoclaw',
|
||||
];
|
||||
|
||||
export const DEFAULT_STALE_AFTER_DAYS = 60;
|
||||
export const FEED_VERSION = '0.1.0';
|
||||
|
||||
const PLATFORM_BY_REPOSITORY = new Map([
|
||||
['openclaw/openclaw', 'openclaw'],
|
||||
['qwibitai/nanoclaw', 'nanoclaw'],
|
||||
['softwarepub/hermes', 'hermes'],
|
||||
['nousresearch/hermes-agent', 'hermes'],
|
||||
['sipeed/picoclaw', 'picoclaw'],
|
||||
]);
|
||||
|
||||
const CWE_TYPE_BY_ID = new Map([
|
||||
['CWE-22', 'path_traversal'],
|
||||
['CWE-78', 'os_command_injection'],
|
||||
['CWE-79', 'cross_site_scripting'],
|
||||
['CWE-94', 'code_injection'],
|
||||
['CWE-200', 'exposure_of_sensitive_information'],
|
||||
['CWE-284', 'improper_access_control'],
|
||||
['CWE-287', 'improper_authentication'],
|
||||
['CWE-306', 'missing_authentication_for_critical_function'],
|
||||
['CWE-352', 'cross_site_request_forgery'],
|
||||
['CWE-400', 'uncontrolled_resource_consumption'],
|
||||
['CWE-502', 'deserialization_of_untrusted_data'],
|
||||
['CWE-862', 'missing_authorization'],
|
||||
['CWE-863', 'incorrect_authorization'],
|
||||
['CWE-918', 'server_side_request_forgery'],
|
||||
]);
|
||||
|
||||
function cleanText(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/\r/g, '')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^#+\s+/gm, '')
|
||||
.replace(/[*_>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function daysBetween(startIso, endIso) {
|
||||
const start = Date.parse(startIso);
|
||||
const end = Date.parse(endIso);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor((end - start) / 86_400_000);
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))];
|
||||
}
|
||||
|
||||
export function inferPlatforms(repository) {
|
||||
const known = PLATFORM_BY_REPOSITORY.get(String(repository).toLowerCase());
|
||||
return known ? [known] : [];
|
||||
}
|
||||
|
||||
function nextLinkFromHeader(linkHeader) {
|
||||
if (!linkHeader) {
|
||||
return null;
|
||||
}
|
||||
for (const part of linkHeader.split(',')) {
|
||||
const match = part.trim().match(/^<([^>]+)>;\s*rel="next"$/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function affectedFromVulnerabilities(advisory, platforms) {
|
||||
const affected = toArray(advisory.vulnerabilities).flatMap((vulnerability) => {
|
||||
const packageName = vulnerability?.package?.name;
|
||||
const versionRange = vulnerability?.vulnerable_version_range;
|
||||
if (!packageName) {
|
||||
return [];
|
||||
}
|
||||
return [`${packageName}@${versionRange || '*'}`];
|
||||
});
|
||||
|
||||
if (affected.length > 0) {
|
||||
return uniqueStrings(affected);
|
||||
}
|
||||
|
||||
return platforms.length > 0 ? platforms.map((platform) => `${platform}@*`) : [];
|
||||
}
|
||||
|
||||
function patchedFromVulnerabilities(advisory) {
|
||||
return uniqueStrings(
|
||||
toArray(advisory.vulnerabilities).flatMap((vulnerability) => {
|
||||
const packageName = vulnerability?.package?.name;
|
||||
const patchedVersions = vulnerability?.patched_versions;
|
||||
if (!packageName || !patchedVersions) {
|
||||
return [];
|
||||
}
|
||||
return [`${packageName}@${patchedVersions}`];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function githubAdvisoryUrl(advisory) {
|
||||
return advisory.html_url || advisory.url || `https://github.com/advisories/${advisory.ghsa_id}`;
|
||||
}
|
||||
|
||||
function resolveCveId(advisory, cveIdByGhsa) {
|
||||
return advisory.cve_id || cveIdByGhsa.get(advisory.ghsa_id) || null;
|
||||
}
|
||||
|
||||
export function normalizeGhsaAdvisory(
|
||||
advisory,
|
||||
{
|
||||
now,
|
||||
repository,
|
||||
staleAfterDays = DEFAULT_STALE_AFTER_DAYS,
|
||||
cveId = advisory.cve_id || null,
|
||||
},
|
||||
) {
|
||||
const platforms = inferPlatforms(repository);
|
||||
const published = advisory.published_at || advisory.created_at || advisory.updated_at || now;
|
||||
const ageDays = daysBetween(published, now);
|
||||
const stale = !cveId && ageDays >= staleAfterDays;
|
||||
const status = cveId ? 'matured' : stale ? 'stale' : 'active';
|
||||
const cweIds = uniqueStrings(toArray(advisory.cwe_ids));
|
||||
const cvss = advisory.cvss || advisory.cvss_severities?.cvss_v3 || {};
|
||||
const ghsaUrl = githubAdvisoryUrl(advisory);
|
||||
const affected = affectedFromVulnerabilities(advisory, platforms);
|
||||
const patched = patchedFromVulnerabilities(advisory);
|
||||
const title = cleanText(advisory.summary) || advisory.ghsa_id;
|
||||
const description = cleanText(advisory.description) || title;
|
||||
|
||||
return {
|
||||
id: advisory.ghsa_id,
|
||||
ghsa_id: advisory.ghsa_id,
|
||||
cve_id: cveId,
|
||||
status,
|
||||
stale,
|
||||
stale_after_days: staleAfterDays,
|
||||
severity: advisory.severity || 'medium',
|
||||
type: CWE_TYPE_BY_ID.get(cweIds[0]) || 'github_security_advisory',
|
||||
nvd_category_id: cweIds[0] || null,
|
||||
title,
|
||||
description,
|
||||
affected,
|
||||
patched,
|
||||
platforms,
|
||||
action: cveId
|
||||
? `Track ${cveId} in the canonical CVE advisory feed and verify affected components.`
|
||||
: 'Review the GitHub Security Advisory and update affected components; no CVE is assigned yet.',
|
||||
published,
|
||||
updated: advisory.updated_at || published,
|
||||
references: uniqueStrings([ghsaUrl, cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null]),
|
||||
source: 'GitHub Security Advisory',
|
||||
repository,
|
||||
github_advisory_url: ghsaUrl,
|
||||
nvd_url: cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
|
||||
cvss_score: cvss.score ?? null,
|
||||
cvss_vector: cvss.vector_string ?? null,
|
||||
cwe_ids: cweIds,
|
||||
credits: uniqueStrings(toArray(advisory.credits).map((credit) => credit?.login)),
|
||||
aliases: uniqueStrings([advisory.ghsa_id, cveId]),
|
||||
};
|
||||
}
|
||||
|
||||
function ghsaToCveMapFromNvdFeed(nvdFeed) {
|
||||
const map = new Map();
|
||||
for (const advisory of toArray(nvdFeed?.advisories)) {
|
||||
const cveId = advisory?.id;
|
||||
if (typeof cveId !== 'string' || !cveId.startsWith('CVE-')) {
|
||||
continue;
|
||||
}
|
||||
const references = toArray(advisory.references).join('\n');
|
||||
for (const match of references.matchAll(/GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/gi)) {
|
||||
map.set(match[0], cveId);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function equivalentAdvisories(left, right) {
|
||||
return JSON.stringify(left ?? []) === JSON.stringify(right ?? []);
|
||||
}
|
||||
|
||||
function isCveId(value) {
|
||||
return typeof value === 'string' && /^CVE-\d{4}-\d{4,}$/i.test(value);
|
||||
}
|
||||
|
||||
function ghsaIdentifier(entry) {
|
||||
if (typeof entry?.ghsa_id === 'string' && entry.ghsa_id.length > 0) {
|
||||
return entry.ghsa_id.toLowerCase();
|
||||
}
|
||||
if (/^GHSA-/i.test(String(entry?.id || ''))) {
|
||||
return String(entry.id).toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function refreshExistingEntry(entry, { now, staleAfterDays, cveIdByGhsa }) {
|
||||
const cveId = entry.cve_id || cveIdByGhsa.get(entry.ghsa_id || entry.id) || null;
|
||||
const ageDays = daysBetween(entry.published, now);
|
||||
const stale = !cveId && ageDays >= staleAfterDays;
|
||||
return {
|
||||
...entry,
|
||||
cve_id: cveId,
|
||||
status: cveId ? 'matured' : stale ? 'stale' : 'active',
|
||||
stale,
|
||||
stale_after_days: staleAfterDays,
|
||||
references: uniqueStrings([
|
||||
...toArray(entry.references),
|
||||
cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
|
||||
]),
|
||||
nvd_url: cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : null,
|
||||
aliases: uniqueStrings([...(entry.aliases || []), entry.ghsa_id || entry.id, cveId]),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConsolidatedAdvisoryFeed({ canonicalFeed = {}, ghsaFeed = {}, now }) {
|
||||
const canonicalFeedEntries = toArray(canonicalFeed.advisories);
|
||||
const canonicalCveIds = new Set(canonicalFeedEntries.map((entry) => entry?.id).filter(isCveId));
|
||||
const replacementGhsaIds = new Set(toArray(ghsaFeed.advisories).map(ghsaIdentifier).filter(Boolean));
|
||||
const canonicalEntries = canonicalFeedEntries.filter((entry) => {
|
||||
const ghsaId = ghsaIdentifier(entry);
|
||||
if (!ghsaId) {
|
||||
return true;
|
||||
}
|
||||
if (entry?.cve_id && canonicalCveIds.has(entry.cve_id)) {
|
||||
return false;
|
||||
}
|
||||
return !replacementGhsaIds.has(ghsaId);
|
||||
});
|
||||
const ghsaEntries = toArray(ghsaFeed.advisories)
|
||||
.filter((entry) => !(entry?.cve_id && canonicalCveIds.has(entry.cve_id)))
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
source_feed: 'ghsa-without-cve',
|
||||
}));
|
||||
|
||||
const advisories = [...canonicalEntries, ...ghsaEntries].sort((a, b) => {
|
||||
const published = Date.parse(b.published || '') - Date.parse(a.published || '');
|
||||
if (Number.isFinite(published) && published !== 0) {
|
||||
return published;
|
||||
}
|
||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
|
||||
return {
|
||||
...canonicalFeed,
|
||||
version: canonicalFeed.version || '1.0.0',
|
||||
updated: canonicalFeed.updated || now,
|
||||
description: canonicalFeed.description || 'Community-driven security advisory feed for ClawSec',
|
||||
advisories,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGhsaWithoutCveFeed({
|
||||
fetched,
|
||||
existingFeed = {},
|
||||
nvdFeed = {},
|
||||
now,
|
||||
staleAfterDays = DEFAULT_STALE_AFTER_DAYS,
|
||||
}) {
|
||||
const existingEntries = toArray(existingFeed.advisories);
|
||||
const existingIds = new Set(existingEntries.map((entry) => entry.ghsa_id || entry.id));
|
||||
const cveIdByGhsa = ghsaToCveMapFromNvdFeed(nvdFeed);
|
||||
const entriesById = new Map();
|
||||
|
||||
for (const { repository, advisories } of fetched) {
|
||||
for (const advisory of advisories) {
|
||||
const ghsaId = advisory.ghsa_id;
|
||||
if (!ghsaId) {
|
||||
continue;
|
||||
}
|
||||
const cveId = resolveCveId(advisory, cveIdByGhsa);
|
||||
if (cveId && !existingIds.has(ghsaId)) {
|
||||
continue;
|
||||
}
|
||||
entriesById.set(
|
||||
ghsaId,
|
||||
normalizeGhsaAdvisory(advisory, {
|
||||
now,
|
||||
repository,
|
||||
staleAfterDays,
|
||||
cveId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of existingEntries) {
|
||||
const ghsaId = entry.ghsa_id || entry.id;
|
||||
if (!ghsaId || entriesById.has(ghsaId)) {
|
||||
continue;
|
||||
}
|
||||
entriesById.set(ghsaId, refreshExistingEntry(entry, { now, staleAfterDays, cveIdByGhsa }));
|
||||
}
|
||||
|
||||
const advisories = [...entriesById.values()].sort((a, b) => {
|
||||
const published = Date.parse(b.published) - Date.parse(a.published);
|
||||
if (published !== 0) {
|
||||
return published;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
const updated = equivalentAdvisories(advisories, existingEntries)
|
||||
? existingFeed.updated || now
|
||||
: now;
|
||||
|
||||
return {
|
||||
version: FEED_VERSION,
|
||||
updated,
|
||||
description:
|
||||
'Provisional ClawSec advisory feed for public GitHub Security Advisories that do not yet have CVE identifiers.',
|
||||
stale_after_days: staleAfterDays,
|
||||
semantics: {
|
||||
active: 'GHSA is published and has no CVE identifier yet.',
|
||||
matured: 'GHSA now has a CVE identifier and should be reconciled with the canonical CVE feed.',
|
||||
stale: 'GHSA is older than stale_after_days and still has no CVE identifier.',
|
||||
},
|
||||
sources: DEFAULT_REPOSITORIES.map((repository) => ({
|
||||
repository,
|
||||
platform: inferPlatforms(repository)[0] || 'unknown',
|
||||
url: `https://github.com/${repository}/security/advisories`,
|
||||
})),
|
||||
advisories,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGitHubSecurityAdvisories(repository, { token } = {}) {
|
||||
const advisories = [];
|
||||
let url = `https://api.github.com/repos/${repository}/security-advisories?per_page=100`;
|
||||
const seenUrls = new Set();
|
||||
|
||||
while (url) {
|
||||
if (seenUrls.has(url)) {
|
||||
throw new Error(`GitHub advisory pagination loop detected for ${repository}: ${url}`);
|
||||
}
|
||||
seenUrls.add(url);
|
||||
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'clawsec-ghsa-without-cve-poller',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(
|
||||
`GitHub advisory fetch failed for ${repository}: HTTP ${response.status} ${message.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pageItems = await response.json();
|
||||
advisories.push(...pageItems);
|
||||
if (!Array.isArray(pageItems)) {
|
||||
break;
|
||||
}
|
||||
url = nextLinkFromHeader(response.headers.get('link'));
|
||||
}
|
||||
return advisories;
|
||||
}
|
||||
|
||||
async function readJsonIfExists(path, fallback) {
|
||||
if (!existsSync(path)) {
|
||||
return fallback;
|
||||
}
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(`${path}.tmp`, `${JSON.stringify(value, null, 2)}\n`);
|
||||
await rename(`${path}.tmp`, path);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
output: 'advisories/ghsa-without-cve.json',
|
||||
consolidatedFeed: null,
|
||||
existingFeed: null,
|
||||
nvdFeed: 'advisories/feed.json',
|
||||
repositories: [...DEFAULT_REPOSITORIES],
|
||||
staleAfterDays: DEFAULT_STALE_AFTER_DAYS,
|
||||
token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '',
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--output') {
|
||||
options.output = argv[++index];
|
||||
} else if (arg === '--consolidated-feed') {
|
||||
options.consolidatedFeed = argv[++index];
|
||||
} else if (arg === '--existing-feed') {
|
||||
options.existingFeed = argv[++index];
|
||||
} else if (arg === '--nvd-feed') {
|
||||
options.nvdFeed = argv[++index];
|
||||
} else if (arg === '--repo') {
|
||||
options.repositories.push(argv[++index]);
|
||||
} else if (arg === '--only-default-repos') {
|
||||
options.repositories = [...DEFAULT_REPOSITORIES];
|
||||
} else if (arg === '--stale-after-days') {
|
||||
options.staleAfterDays = Number.parseInt(argv[++index], 10);
|
||||
} else if (arg === '--help') {
|
||||
options.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isInteger(options.staleAfterDays) || options.staleAfterDays < 1) {
|
||||
throw new Error('--stale-after-days must be a positive integer');
|
||||
}
|
||||
|
||||
options.repositories = uniqueStrings(options.repositories.map((repo) => repo.toLowerCase()));
|
||||
options.existingFeed ||= options.output;
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/ghsa-without-cve-feed.mjs [options]
|
||||
|
||||
Options:
|
||||
--output PATH Feed output path (default: advisories/ghsa-without-cve.json)
|
||||
--consolidated-feed PATH Also merge active GHSA advisories into agent-facing feed PATH
|
||||
--existing-feed PATH Existing provisional feed path (default: output path)
|
||||
--nvd-feed PATH Canonical CVE feed path for GHSA-to-CVE reconciliation
|
||||
--repo OWNER/NAME Additional repository to poll
|
||||
--only-default-repos Reset repository list to built-in ClawSec sources
|
||||
--stale-after-days N Mark GHSA-only advisories stale after N days (default: 60)
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const fetched = [];
|
||||
for (const repository of options.repositories) {
|
||||
const advisories = await fetchGitHubSecurityAdvisories(repository, { token: options.token });
|
||||
console.log(`Fetched ${advisories.length} GitHub Security Advisories from ${repository}`);
|
||||
fetched.push({ repository, advisories });
|
||||
}
|
||||
|
||||
const existingFeed = await readJsonIfExists(options.existingFeed, {});
|
||||
const nvdFeed = await readJsonIfExists(options.nvdFeed, { advisories: [] });
|
||||
const feed = buildGhsaWithoutCveFeed({
|
||||
fetched,
|
||||
existingFeed,
|
||||
nvdFeed,
|
||||
now,
|
||||
staleAfterDays: options.staleAfterDays,
|
||||
});
|
||||
|
||||
await writeJson(options.output, feed);
|
||||
console.log(`Wrote ${feed.advisories.length} provisional GHSA advisories to ${options.output}`);
|
||||
|
||||
if (options.consolidatedFeed) {
|
||||
const canonicalFeed = await readJsonIfExists(options.consolidatedFeed, {
|
||||
version: '1.0.0',
|
||||
advisories: [],
|
||||
});
|
||||
const consolidatedFeed = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed: feed,
|
||||
now,
|
||||
});
|
||||
await writeJson(options.consolidatedFeed, consolidatedFeed);
|
||||
console.log(
|
||||
`Wrote ${consolidatedFeed.advisories.length} consolidated agent advisories to ${options.consolidatedFeed}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Status counts: ${JSON.stringify(
|
||||
feed.advisories.reduce((counts, advisory) => {
|
||||
counts[advisory.status] = (counts[advisory.status] || 0) + 1;
|
||||
return counts;
|
||||
}, {}),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const workflowPath = new URL("../.github/workflows/deploy-pages.yml", import.meta.url);
|
||||
const workflow = await readFile(workflowPath, "utf8");
|
||||
|
||||
function stepIndex(name) {
|
||||
const marker = `- name: ${name}`;
|
||||
const index = workflow.indexOf(marker);
|
||||
assert.notEqual(index, -1, `missing workflow step: ${name}`);
|
||||
return index;
|
||||
}
|
||||
|
||||
const signFeedIndex = stepIndex("Sign advisory feed and verify");
|
||||
const signGhsaIndex = stepIndex("Sign provisional GHSA feed and verify");
|
||||
const generateChecksumsIndex = stepIndex("Generate advisory checksums manifest");
|
||||
const signChecksumsIndex = stepIndex("Sign checksums and verify");
|
||||
|
||||
assert.ok(
|
||||
signFeedIndex < generateChecksumsIndex,
|
||||
"advisory checksums manifest must be generated after feed.json.sig is created",
|
||||
);
|
||||
assert.ok(
|
||||
signGhsaIndex < generateChecksumsIndex,
|
||||
"advisory checksums manifest must be generated after ghsa-without-cve.json.sig is created",
|
||||
);
|
||||
assert.ok(
|
||||
generateChecksumsIndex < signChecksumsIndex,
|
||||
"checksums signature must be generated after checksums.json is refreshed",
|
||||
);
|
||||
|
||||
const generateStepBody = workflow.slice(generateChecksumsIndex, signChecksumsIndex);
|
||||
assert.match(
|
||||
generateStepBody,
|
||||
/public\/advisories\/\*\.json\.sig/,
|
||||
"advisory checksums manifest must include detached advisory signatures",
|
||||
);
|
||||
|
||||
const mirrorBlockIndex = workflow.indexOf(
|
||||
"# Mirror advisories feed + signatures at the path referenced by suite docs/heartbeat",
|
||||
);
|
||||
assert.notEqual(mirrorBlockIndex, -1, "missing advisory release mirror block");
|
||||
|
||||
const mirrorBlock = workflow.slice(mirrorBlockIndex, workflow.indexOf("if [ -f \"public/checksums.json\"", mirrorBlockIndex));
|
||||
assert.match(
|
||||
mirrorBlock,
|
||||
/cp "public\/advisories\/ghsa-without-cve\.json" "\$MIRROR_LATEST_DIR\/ghsa-without-cve\.json"/,
|
||||
"GHSA provisional feed must be mirrored at the release-root compatibility path",
|
||||
);
|
||||
assert.match(
|
||||
mirrorBlock,
|
||||
/cp "public\/advisories\/ghsa-without-cve\.json\.sig" "\$MIRROR_LATEST_DIR\/ghsa-without-cve\.json\.sig"/,
|
||||
"GHSA provisional feed signature must be mirrored at the release-root compatibility path",
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const workflowPath = new URL('../.github/workflows/poll-ghsa-without-cve.yml', import.meta.url);
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
|
||||
assert.match(workflow, /workflow_dispatch:/, 'GHSA poll workflow must remain runnable as a manual fallback');
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/\n\s+schedule:/,
|
||||
'Scheduled GHSA consolidation belongs to the NVD workflow to avoid duplicate automated feed PRs',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/FEED_PATH:\s+advisories\/feed\.json/,
|
||||
'GHSA poll workflow must know the consolidated agent feed path',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/SKILL_FEED_PATH:\s+skills\/clawsec-feed\/advisories\/feed\.json/,
|
||||
'GHSA poll workflow must sync the consolidated agent feed into clawsec-feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/--consolidated-feed "\$FEED_PATH"/,
|
||||
'GHSA poll workflow must merge GHSA advisories into the agent-facing feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/input_file: \$\{\{ env\.FEED_PATH \}\}/,
|
||||
'GHSA poll workflow must sign the consolidated agent feed when it changes',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/cp "\$FEED_SIG_PATH" "\$SKILL_FEED_SIG_PATH"/,
|
||||
'GHSA poll workflow must sync consolidated feed signature into clawsec-feed',
|
||||
);
|
||||
@@ -0,0 +1,425 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildConsolidatedAdvisoryFeed,
|
||||
buildGhsaWithoutCveFeed,
|
||||
fetchGitHubSecurityAdvisories,
|
||||
inferPlatforms,
|
||||
normalizeGhsaAdvisory,
|
||||
} from './ghsa-without-cve-feed.mjs';
|
||||
|
||||
const fixedNow = '2026-05-24T00:00:00Z';
|
||||
|
||||
function advisory(overrides = {}) {
|
||||
return {
|
||||
ghsa_id: 'GHSA-test-1111-2222',
|
||||
cve_id: null,
|
||||
html_url: 'https://github.com/openclaw/openclaw/security/advisories/GHSA-test-1111-2222',
|
||||
summary: 'Workspace bridge allows sandbox escape',
|
||||
description: 'OpenClaw before 2026.4.25 allowed a sandbox escape.',
|
||||
severity: 'high',
|
||||
published_at: '2026-04-24T00:00:00Z',
|
||||
updated_at: '2026-04-25T00:00:00Z',
|
||||
vulnerabilities: [
|
||||
{
|
||||
package: { ecosystem: 'npm', name: 'openclaw' },
|
||||
vulnerable_version_range: '<2026.4.25',
|
||||
patched_versions: '2026.4.25',
|
||||
},
|
||||
],
|
||||
cvss: {
|
||||
vector_string: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H',
|
||||
score: 7.8,
|
||||
},
|
||||
cwe_ids: ['CWE-94'],
|
||||
credits: [{ login: 'researcher', type: 'reporter' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('inferPlatforms maps known repositories to feed platforms', () => {
|
||||
assert.deepEqual(inferPlatforms('openclaw/openclaw'), ['openclaw']);
|
||||
assert.deepEqual(inferPlatforms('qwibitai/nanoclaw'), ['nanoclaw']);
|
||||
assert.deepEqual(inferPlatforms('softwarepub/hermes'), ['hermes']);
|
||||
assert.deepEqual(inferPlatforms('sipeed/picoclaw'), ['picoclaw']);
|
||||
});
|
||||
|
||||
test('fetchGitHubSecurityAdvisories follows cursor pagination links', async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const nextUrl =
|
||||
'https://api.github.com/repositories/1103012935/security-advisories?per_page=100&after=cursor';
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
if (calls.length === 1) {
|
||||
return new globalThis.Response(
|
||||
JSON.stringify(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
advisory({ ghsa_id: `GHSA-page-1111-${String(index).padStart(4, '0')}` }),
|
||||
),
|
||||
),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
Link: `<${nextUrl}>; rel="next"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (String(url) !== nextUrl) {
|
||||
throw new Error(`unexpected pagination URL: ${url}`);
|
||||
}
|
||||
return new globalThis.Response(JSON.stringify([advisory({ ghsa_id: 'GHSA-next-1111-2222' })]), {
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const advisories = await fetchGitHubSecurityAdvisories('openclaw/openclaw', {
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[1], nextUrl);
|
||||
assert.equal(advisories.length, 101);
|
||||
assert.equal(advisories.at(-1).ghsa_id, 'GHSA-next-1111-2222');
|
||||
});
|
||||
|
||||
test('normalizeGhsaAdvisory marks fresh GHSA-only advisories active', () => {
|
||||
const normalized = normalizeGhsaAdvisory(advisory(), {
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
});
|
||||
|
||||
assert.equal(normalized.id, 'GHSA-test-1111-2222');
|
||||
assert.equal(normalized.status, 'active');
|
||||
assert.equal(normalized.cve_id, null);
|
||||
assert.equal(normalized.stale, false);
|
||||
assert.deepEqual(normalized.platforms, ['openclaw']);
|
||||
assert.deepEqual(normalized.affected, ['openclaw@<2026.4.25']);
|
||||
});
|
||||
|
||||
test('normalizeGhsaAdvisory marks old GHSA-only advisories stale after threshold', () => {
|
||||
const normalized = normalizeGhsaAdvisory(
|
||||
advisory({ published_at: '2026-03-01T00:00:00Z' }),
|
||||
{
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(normalized.status, 'stale');
|
||||
assert.equal(normalized.stale, true);
|
||||
assert.equal(normalized.cve_id, null);
|
||||
});
|
||||
|
||||
test('normalizeGhsaAdvisory marks existing GHSA entries matured when a CVE appears', () => {
|
||||
const normalized = normalizeGhsaAdvisory(
|
||||
advisory({ cve_id: 'CVE-2026-9999' }),
|
||||
{
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(normalized.status, 'matured');
|
||||
assert.equal(normalized.stale, false);
|
||||
assert.equal(normalized.cve_id, 'CVE-2026-9999');
|
||||
assert.equal(normalized.nvd_url, 'https://nvd.nist.gov/vuln/detail/CVE-2026-9999');
|
||||
});
|
||||
|
||||
test('buildGhsaWithoutCveFeed only imports CVE-backed advisories that were already tracked', () => {
|
||||
const existing = {
|
||||
version: '0.1.0',
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-old-1111-2222' }), {
|
||||
now: '2026-04-25T00:00:00Z',
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const fetched = [
|
||||
{
|
||||
repository: 'openclaw/openclaw',
|
||||
advisories: [
|
||||
advisory({ ghsa_id: 'GHSA-new-1111-2222', cve_id: null }),
|
||||
advisory({ ghsa_id: 'GHSA-old-1111-2222', cve_id: 'CVE-2026-1111' }),
|
||||
advisory({ ghsa_id: 'GHSA-cve-only-1111-2222', cve_id: 'CVE-2026-2222' }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const feed = buildGhsaWithoutCveFeed({
|
||||
fetched,
|
||||
existingFeed: existing,
|
||||
nvdFeed: { advisories: [] },
|
||||
now: fixedNow,
|
||||
staleAfterDays: 60,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
feed.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
|
||||
[
|
||||
['GHSA-new-1111-2222', 'active', null],
|
||||
['GHSA-old-1111-2222', 'matured', 'CVE-2026-1111'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildGhsaWithoutCveFeed matures tracked GHSAs when the CVE feed references them', () => {
|
||||
const existing = {
|
||||
version: '0.1.0',
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-oooo-3333-4444' }), {
|
||||
now: '2026-04-25T00:00:00Z',
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const feed = buildGhsaWithoutCveFeed({
|
||||
fetched: [
|
||||
{
|
||||
repository: 'openclaw/openclaw',
|
||||
advisories: [advisory({ ghsa_id: 'GHSA-oooo-3333-4444', cve_id: null })],
|
||||
},
|
||||
],
|
||||
existingFeed: existing,
|
||||
nvdFeed: {
|
||||
advisories: [
|
||||
{
|
||||
id: 'CVE-2026-3333',
|
||||
references: [
|
||||
'https://github.com/openclaw/openclaw/security/advisories/GHSA-oooo-3333-4444',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
now: fixedNow,
|
||||
staleAfterDays: 60,
|
||||
});
|
||||
|
||||
assert.equal(feed.advisories[0].status, 'matured');
|
||||
assert.equal(feed.advisories[0].cve_id, 'CVE-2026-3333');
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed appends active GHSA advisories without moving the NVD poll cursor', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
description: 'Community-driven security advisory feed for ClawSec',
|
||||
advisories: [
|
||||
{
|
||||
id: 'CVE-2026-1111',
|
||||
severity: 'high',
|
||||
type: 'os_command_injection',
|
||||
title: 'Existing CVE',
|
||||
description: 'Existing CVE advisory',
|
||||
affected: ['openclaw@*'],
|
||||
platforms: ['openclaw'],
|
||||
action: 'Review NVD.',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
const ghsaFeed = {
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(advisory({ ghsa_id: 'GHSA-active-1111-2222', cve_id: null }), {
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed,
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => entry.id),
|
||||
['CVE-2026-1111', 'GHSA-active-1111-2222'],
|
||||
);
|
||||
assert.equal(consolidated.updated, canonicalFeed.updated);
|
||||
assert.equal(consolidated.advisories[1].source_feed, 'ghsa-without-cve');
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed keeps existing GHSA advisories when replacement feed is empty', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
advisories: [
|
||||
{
|
||||
id: 'CVE-2026-1111',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-keep-1111-2222',
|
||||
ghsa_id: 'GHSA-keep-1111-2222',
|
||||
status: 'active',
|
||||
published: '2026-05-02T00:00:00Z',
|
||||
source_feed: 'ghsa-without-cve',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed: { advisories: [] },
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => entry.id),
|
||||
['GHSA-keep-1111-2222', 'CVE-2026-1111'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed replaces only matching GHSA canonical entries', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
advisories: [
|
||||
{
|
||||
id: 'GHSA-repl-1111-2222',
|
||||
ghsa_id: 'GHSA-repl-1111-2222',
|
||||
status: 'active',
|
||||
title: 'Old GHSA payload',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
source_feed: 'ghsa-without-cve',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-keep-3333-4444',
|
||||
ghsa_id: 'GHSA-keep-3333-4444',
|
||||
status: 'active',
|
||||
title: 'Retained GHSA payload',
|
||||
published: '2026-05-02T00:00:00Z',
|
||||
source_feed: 'ghsa-without-cve',
|
||||
},
|
||||
],
|
||||
};
|
||||
const ghsaFeed = {
|
||||
advisories: [
|
||||
{
|
||||
id: 'GHSA-repl-1111-2222',
|
||||
ghsa_id: 'GHSA-repl-1111-2222',
|
||||
status: 'stale',
|
||||
title: 'Replacement GHSA payload',
|
||||
published: '2026-05-03T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed,
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => [entry.id, entry.title, entry.status]),
|
||||
[
|
||||
['GHSA-repl-1111-2222', 'Replacement GHSA payload', 'stale'],
|
||||
['GHSA-keep-3333-4444', 'Retained GHSA payload', 'active'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed drops GHSA duplicate when matching CVE is present', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
advisories: [
|
||||
{
|
||||
id: 'CVE-2026-2222',
|
||||
severity: 'high',
|
||||
type: 'code_injection',
|
||||
title: 'Canonical CVE',
|
||||
description: 'Canonical CVE advisory',
|
||||
affected: ['openclaw@*'],
|
||||
platforms: ['openclaw'],
|
||||
action: 'Review NVD.',
|
||||
published: '2026-05-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-old-duplicate',
|
||||
ghsa_id: 'GHSA-old-duplicate',
|
||||
cve_id: 'CVE-2026-2222',
|
||||
status: 'matured',
|
||||
source_feed: 'ghsa-without-cve',
|
||||
severity: 'high',
|
||||
type: 'github_security_advisory',
|
||||
title: 'Old duplicate',
|
||||
description: 'Old provisional duplicate',
|
||||
affected: ['openclaw@*'],
|
||||
platforms: ['openclaw'],
|
||||
action: 'Track CVE.',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
const ghsaFeed = {
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(
|
||||
advisory({ ghsa_id: 'GHSA-new-duplicate', cve_id: 'CVE-2026-2222' }),
|
||||
{
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed,
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => entry.id),
|
||||
['CVE-2026-2222'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConsolidatedAdvisoryFeed keeps matured GHSA until CVE lands in canonical feed', () => {
|
||||
const canonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
advisories: [],
|
||||
};
|
||||
const ghsaFeed = {
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(
|
||||
advisory({ ghsa_id: 'GHSA-matured-1111-2222', cve_id: 'CVE-2026-4444' }),
|
||||
{
|
||||
now: fixedNow,
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const consolidated = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed,
|
||||
ghsaFeed,
|
||||
now: fixedNow,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
consolidated.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
|
||||
[['GHSA-matured-1111-2222', 'matured', 'CVE-2026-4444']],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const workflowPath = new URL('../.github/workflows/poll-nvd-cves.yml', import.meta.url);
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
const ciWorkflowPath = new URL('../.github/workflows/ci.yml', import.meta.url);
|
||||
const ciWorkflow = await readFile(ciWorkflowPath, 'utf8');
|
||||
|
||||
function requiredIndex(snippet, message) {
|
||||
const index = workflow.indexOf(snippet);
|
||||
assert.notEqual(index, -1, message);
|
||||
return index;
|
||||
}
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/GHSA_FEED_PATH:\s+advisories\/ghsa-without-cve\.json/,
|
||||
'NVD workflow must write the provisional GHSA source feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/GHSA_FEED_SIG_PATH:\s+advisories\/ghsa-without-cve\.json\.sig/,
|
||||
'NVD workflow must sign the provisional GHSA source feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/node scripts\/ghsa-without-cve-feed\.mjs[\s\S]*--output "\$GHSA_FEED_PATH"[\s\S]*--consolidated-feed "\$FEED_PATH"[\s\S]*--existing-feed "\$GHSA_FEED_PATH"[\s\S]*--nvd-feed "\$FEED_PATH"/,
|
||||
'NVD workflow must merge GHSA advisories into the signed agent feed',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/id: feed_changes[\s\S]*ghsa_changed=\$GHSA_CHANGED[\s\S]*agent_changed=\$AGENT_CHANGED[\s\S]*changed=true/,
|
||||
'NVD workflow must detect GHSA and consolidated agent feed changes separately',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/if: steps\.feed_changes\.outputs\.ghsa_changed == 'true'[\s\S]*input_file: \$\{\{ env\.GHSA_FEED_PATH \}\}[\s\S]*signature_file: \$\{\{ env\.GHSA_FEED_SIG_PATH \}\}/,
|
||||
'NVD workflow must sign the provisional GHSA feed when it changes',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/if: steps\.feed_changes\.outputs\.agent_changed == 'true'[\s\S]*input_file: \$\{\{ env\.FEED_PATH \}\}[\s\S]*signature_file: \$\{\{ env\.FEED_SIG_PATH \}\}/,
|
||||
'NVD workflow must sign the consolidated agent feed when it changes',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/git add "\$FEED_PATH" "\$FEED_SIG_PATH" "\$GHSA_FEED_PATH" "\$GHSA_FEED_SIG_PATH" "\$SKILL_FEED_PATH" "\$SKILL_FEED_SIG_PATH"/,
|
||||
'NVD workflow PR must include both NVD and GHSA feed artifacts',
|
||||
);
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/name: NVD \+ GHSA Pipeline Dry Run[\s\S]*node scripts\/test-nvd-ghsa-pipeline-dry-run\.mjs/,
|
||||
'CI must run the deterministic NVD + GHSA pipeline dry run before merge',
|
||||
);
|
||||
|
||||
const updateFeedIndex = requiredIndex('name: Update feed.json', 'NVD workflow must update the CVE feed first');
|
||||
const pollGhsaIndex = requiredIndex(
|
||||
'name: Poll GHSA without CVE and consolidate feed',
|
||||
'NVD workflow must poll GHSA before signing',
|
||||
);
|
||||
const detectChangesIndex = requiredIndex(
|
||||
'name: Detect advisory feed changes',
|
||||
'NVD workflow must detect combined feed changes before signing',
|
||||
);
|
||||
const signGhsaIndex = requiredIndex(
|
||||
'name: Sign GHSA feed and verify',
|
||||
'NVD workflow must sign the GHSA source feed',
|
||||
);
|
||||
const signAgentIndex = requiredIndex(
|
||||
'name: Sign advisory feed and verify',
|
||||
'NVD workflow must sign the consolidated agent feed',
|
||||
);
|
||||
const upsertPrIndex = requiredIndex(
|
||||
'name: Upsert NVD advisory PR',
|
||||
'NVD workflow must upsert a PR for any feed change',
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
updateFeedIndex < pollGhsaIndex,
|
||||
'GHSA consolidation must run after the NVD update step so matured advisories can reconcile against new CVEs',
|
||||
);
|
||||
assert.ok(
|
||||
pollGhsaIndex < detectChangesIndex,
|
||||
'Combined feed change detection must run after GHSA consolidation',
|
||||
);
|
||||
assert.ok(detectChangesIndex < signGhsaIndex, 'GHSA signing must run after change detection');
|
||||
assert.ok(detectChangesIndex < signAgentIndex, 'Agent feed signing must run after change detection');
|
||||
assert.ok(signAgentIndex < upsertPrIndex, 'The PR must be created after feed signing');
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPairSync, sign, verify } from 'node:crypto';
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
buildConsolidatedAdvisoryFeed,
|
||||
buildGhsaWithoutCveFeed,
|
||||
normalizeGhsaAdvisory,
|
||||
} from './ghsa-without-cve-feed.mjs';
|
||||
|
||||
const now = '2026-05-24T00:00:00Z';
|
||||
|
||||
function cveAdvisory(overrides = {}) {
|
||||
return {
|
||||
id: 'CVE-2026-1111',
|
||||
severity: 'high',
|
||||
type: 'code_injection',
|
||||
title: 'OpenClaw command execution advisory',
|
||||
description: 'OpenClaw allowed unsafe tool execution in a guarded workspace.',
|
||||
affected: ['openclaw@<2026.5.20'],
|
||||
patched: ['openclaw@2026.5.20'],
|
||||
platforms: ['openclaw'],
|
||||
action: 'Update OpenClaw and verify guarded workspace execution.',
|
||||
published: '2026-05-01T00:00:00Z',
|
||||
updated: '2026-05-01T00:00:00Z',
|
||||
references: ['https://nvd.nist.gov/vuln/detail/CVE-2026-1111'],
|
||||
nvd_url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-1111',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ghsaAdvisory(overrides = {}) {
|
||||
return {
|
||||
ghsa_id: 'GHSA-actv-1111-2222',
|
||||
cve_id: null,
|
||||
html_url: 'https://github.com/openclaw/openclaw/security/advisories/GHSA-actv-1111-2222',
|
||||
summary: 'OpenClaw advisory without CVE',
|
||||
description: 'OpenClaw published a public GitHub advisory before CVE assignment.',
|
||||
severity: 'high',
|
||||
published_at: '2026-05-20T00:00:00Z',
|
||||
updated_at: '2026-05-21T00:00:00Z',
|
||||
vulnerabilities: [
|
||||
{
|
||||
package: { ecosystem: 'npm', name: 'openclaw' },
|
||||
vulnerable_version_range: '<2026.5.21',
|
||||
patched_versions: '2026.5.21',
|
||||
},
|
||||
],
|
||||
cvss: {
|
||||
vector_string: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H',
|
||||
score: 7.8,
|
||||
},
|
||||
cwe_ids: ['CWE-94'],
|
||||
credits: [{ login: 'security-researcher', type: 'reporter' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function signBuffer(data, privateKey) {
|
||||
return sign(null, data, privateKey).toString('base64');
|
||||
}
|
||||
|
||||
function verifySignature(data, signature, publicKey) {
|
||||
return verify(null, data, publicKey, Buffer.from(signature, 'base64'));
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'clawsec-nvd-ghsa-ci-dry-run-'));
|
||||
const canonicalFeedPath = path.join(tempDir, 'advisories/feed.json');
|
||||
const ghsaFeedPath = path.join(tempDir, 'advisories/ghsa-without-cve.json');
|
||||
const skillFeedPath = path.join(tempDir, 'skills/clawsec-feed/advisories/feed.json');
|
||||
|
||||
const existingCanonicalFeed = {
|
||||
version: '1.0.0',
|
||||
updated: '2026-05-23T00:00:00Z',
|
||||
description: 'Community-driven security advisory feed for ClawSec',
|
||||
advisories: [
|
||||
cveAdvisory({
|
||||
id: 'CVE-2026-1111',
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2026-1111',
|
||||
'https://github.com/openclaw/openclaw/security/advisories/GHSA-matd-1111-2222',
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
const nvdPollResultFeed = {
|
||||
...existingCanonicalFeed,
|
||||
updated: now,
|
||||
advisories: [
|
||||
cveAdvisory({
|
||||
id: 'CVE-2026-2222',
|
||||
title: 'Fresh NVD advisory from the poll window',
|
||||
published: '2026-05-24T00:00:00Z',
|
||||
updated: '2026-05-24T00:00:00Z',
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2026-2222',
|
||||
'https://github.com/openclaw/openclaw/security/advisories/GHSA-cvea-1111-2222',
|
||||
],
|
||||
nvd_url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-2222',
|
||||
}),
|
||||
...existingCanonicalFeed.advisories,
|
||||
],
|
||||
};
|
||||
const existingGhsaFeed = {
|
||||
version: '0.1.0',
|
||||
updated: '2026-05-20T00:00:00Z',
|
||||
advisories: [
|
||||
normalizeGhsaAdvisory(ghsaAdvisory({ ghsa_id: 'GHSA-matd-1111-2222' }), {
|
||||
now: '2026-05-20T00:00:00Z',
|
||||
repository: 'openclaw/openclaw',
|
||||
staleAfterDays: 60,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const fetchedGhsaAdvisories = [
|
||||
{
|
||||
repository: 'openclaw/openclaw',
|
||||
advisories: [
|
||||
ghsaAdvisory({ ghsa_id: 'GHSA-actv-1111-2222' }),
|
||||
ghsaAdvisory({ ghsa_id: 'GHSA-matd-1111-2222' }),
|
||||
ghsaAdvisory({ ghsa_id: 'GHSA-cvea-1111-2222', cve_id: 'CVE-2026-2222' }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ghsaFeed = buildGhsaWithoutCveFeed({
|
||||
fetched: fetchedGhsaAdvisories,
|
||||
existingFeed: existingGhsaFeed,
|
||||
nvdFeed: nvdPollResultFeed,
|
||||
now,
|
||||
staleAfterDays: 60,
|
||||
});
|
||||
assert.deepEqual(
|
||||
ghsaFeed.advisories.map((entry) => [entry.id, entry.status, entry.cve_id]),
|
||||
[
|
||||
['GHSA-actv-1111-2222', 'active', null],
|
||||
['GHSA-matd-1111-2222', 'matured', 'CVE-2026-1111'],
|
||||
],
|
||||
'GHSA dry run should retain active GHSA-only advisories and mature tracked GHSAs',
|
||||
);
|
||||
|
||||
const consolidatedFeed = buildConsolidatedAdvisoryFeed({
|
||||
canonicalFeed: nvdPollResultFeed,
|
||||
ghsaFeed,
|
||||
now,
|
||||
});
|
||||
assert.deepEqual(
|
||||
consolidatedFeed.advisories.map((entry) => entry.id),
|
||||
['CVE-2026-2222', 'GHSA-actv-1111-2222', 'CVE-2026-1111'],
|
||||
'Consolidated feed should include NVD CVEs plus active GHSA-only advisories without duplicate matured GHSAs',
|
||||
);
|
||||
assert.equal(consolidatedFeed.advisories[1].source_feed, 'ghsa-without-cve');
|
||||
assert.equal(consolidatedFeed.updated, nvdPollResultFeed.updated);
|
||||
|
||||
await writeJson(canonicalFeedPath, consolidatedFeed);
|
||||
await writeJson(ghsaFeedPath, ghsaFeed);
|
||||
await writeJson(skillFeedPath, consolidatedFeed);
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
||||
const canonicalFeedBytes = await readFile(canonicalFeedPath);
|
||||
const ghsaFeedBytes = await readFile(ghsaFeedPath);
|
||||
const skillFeedBytes = await readFile(skillFeedPath);
|
||||
const canonicalSignature = signBuffer(canonicalFeedBytes, privateKey);
|
||||
const ghsaSignature = signBuffer(ghsaFeedBytes, privateKey);
|
||||
|
||||
await writeFile(`${canonicalFeedPath}.sig`, `${canonicalSignature}\n`);
|
||||
await writeFile(`${ghsaFeedPath}.sig`, `${ghsaSignature}\n`);
|
||||
await writeFile(`${skillFeedPath}.sig`, `${canonicalSignature}\n`);
|
||||
|
||||
assert.deepEqual(skillFeedBytes, canonicalFeedBytes, 'skill advisory feed must match the signed agent feed');
|
||||
assert.ok(
|
||||
verifySignature(canonicalFeedBytes, canonicalSignature, publicKey),
|
||||
'canonical consolidated feed signature must verify',
|
||||
);
|
||||
assert.ok(verifySignature(skillFeedBytes, canonicalSignature, publicKey), 'skill feed signature must verify');
|
||||
assert.ok(verifySignature(ghsaFeedBytes, ghsaSignature, publicKey), 'GHSA source feed signature must verify');
|
||||
|
||||
console.log(
|
||||
`NVD + GHSA dry run passed: ${consolidatedFeed.advisories.length} consolidated advisories, ${ghsaFeed.advisories.length} GHSA source advisories, signatures verified.`,
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const workflowPath = new URL('../.github/workflows/skill-release.yml', import.meta.url);
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/pull_request:[\s\S]*paths:[\s\S]*- 'skills\/\*\*'/,
|
||||
'Skill release workflow must run when any skill package file changes',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/git diff --name-only "\$\{BASE_SHA\}\.\.\.\$\{HEAD_SHA\}" --[\s\S]*'skills\/\*\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/test\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/tests\/\*\*'/,
|
||||
'Skill release validation must ignore test-only skill changes while inspecting release-relevant skill files',
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
workflow,
|
||||
/No version bump detected for \$\{skill_dir\}; skipping\./,
|
||||
'Changed skill directories without a version bump must fail validation instead of being skipped',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/::error file=\$\{skill_dir\}::Changed skill package has no version bump\./,
|
||||
'Skill release validation must emit an explicit missing-version-bump error',
|
||||
);
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.8] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
- Documented the consolidated signed advisory feed as the default feed for NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
|
||||
|
||||
## [0.0.7] - 2026-05-14
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-feed
|
||||
version: 0.0.7
|
||||
version: 0.0.8
|
||||
description: Security advisory feed package for OpenClaw-related threats and vulnerabilities. The upstream feed is updated daily; local automation is handled by clawsec-suite or the operator.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"📡","category":"security"}}
|
||||
@@ -14,7 +14,7 @@ clawdis:
|
||||
|
||||
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 and Moltbot from the NIST National Vulnerability Database (NVD).
|
||||
The default `feed.json` is the consolidated agent feed. It includes NVD CVEs, approved community advisories, and provisional GitHub Security Advisories that do not have CVE IDs yet.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
@@ -90,7 +90,7 @@ For standalone installs, verify the signed release manifest before trusting `SKI
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_NAME="clawsec-feed"
|
||||
VERSION="0.0.7"
|
||||
VERSION="0.0.8"
|
||||
REPO="prompt-security/clawsec"
|
||||
TAG="${SKILL_NAME}-v${VERSION}"
|
||||
BASE="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
@@ -783,7 +783,7 @@ fi
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `CLAWSEC_FEED_URL` | Custom advisory feed URL | Raw GitHub (`main` branch) |
|
||||
| `CLAWSEC_FEED_URL` | Custom advisory feed URL | Consolidated signed feed |
|
||||
| `CLAWSEC_INSTALL_DIR` | Installation directory | `~/.openclaw/skills/clawsec-feed` |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-feed",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.8",
|
||||
"description": "Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.6] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
- Documented that NanoClaw consumes the consolidated signed advisory feed containing NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
|
||||
- Added advisory metadata typing for GHSA lifecycle fields used by the consolidated feed.
|
||||
|
||||
## [0.0.5] - 2026-05-14
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-nanoclaw
|
||||
version: 0.0.5
|
||||
version: 0.0.6
|
||||
description: Use when checking for security vulnerabilities in NanoClaw skills, before installing new skills, or when asked about security advisories affecting the bot
|
||||
---
|
||||
|
||||
@@ -183,6 +183,8 @@ if (advisory.exploitability_score === 'high' || advisory.severity === 'critical'
|
||||
|
||||
**Feed Source**: https://clawsec.prompt.security/advisories/feed.json
|
||||
|
||||
This signed feed is consolidated. NanoClaw receives NVD CVEs, approved community advisories, and provisional GHSA-without-CVE advisories through the same default URL.
|
||||
|
||||
**Update Frequency**: Every 6 hours (automatic)
|
||||
|
||||
**Signature Verification**: Ed25519 signed feeds
|
||||
@@ -208,7 +210,7 @@ For standalone installs, verify the signed release manifest before trusting `SKI
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_NAME="clawsec-nanoclaw"
|
||||
VERSION="0.0.5"
|
||||
VERSION="0.0.6"
|
||||
REPO="prompt-security/clawsec"
|
||||
TAG="${SKILL_NAME}-v${VERSION}"
|
||||
BASE="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
export interface Advisory {
|
||||
id: string;
|
||||
ghsa_id?: string;
|
||||
cve_id?: string | null;
|
||||
status?: 'active' | 'matured' | 'stale' | string;
|
||||
stale?: boolean;
|
||||
source_feed?: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
type: 'vulnerable_skill' | 'malicious_skill' | 'prompt_injection' | string;
|
||||
title: string;
|
||||
@@ -14,7 +19,10 @@ export interface Advisory {
|
||||
published: string;
|
||||
references: string[];
|
||||
cvss_score?: number;
|
||||
cvss_vector?: string | null;
|
||||
nvd_url?: string;
|
||||
github_advisory_url?: string;
|
||||
platforms?: string[];
|
||||
exploitability_score?: 'high' | 'medium' | 'low' | 'unknown';
|
||||
exploitability_rationale?: string;
|
||||
source?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-nanoclaw",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"description": "ClawSec security suite for NanoClaw - Advisory feed monitoring, MCP tools for vulnerability checking, and Ed25519 signature verification for containerized WhatsApp bot agents",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -5,6 +5,13 @@ 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.1.9] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Documented the remote advisory feed as a consolidated feed containing NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
|
||||
- Added advisory guardian type coverage for GHSA lifecycle metadata used by the consolidated feed.
|
||||
|
||||
## [0.1.8] - 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-suite
|
||||
version: 0.1.8
|
||||
version: 0.1.9
|
||||
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:
|
||||
@@ -28,7 +28,7 @@ This means `clawsec-suite` can:
|
||||
## Included vs Optional Protections
|
||||
|
||||
### Built into clawsec-suite
|
||||
- Embedded feed seed file: `advisories/feed.json`
|
||||
- Embedded consolidated advisory 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/`
|
||||
@@ -200,7 +200,8 @@ This enforces:
|
||||
|
||||
The embedded feed logic uses these defaults:
|
||||
|
||||
- Remote feed URL: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
- Remote consolidated feed URL: `https://clawsec.prompt.security/advisories/feed.json`
|
||||
- Feed contents: NVD CVEs, approved community advisories, and provisional GHSA-without-CVE advisories.
|
||||
- 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`
|
||||
|
||||
@@ -6,6 +6,11 @@ export type HookEvent = {
|
||||
|
||||
export type Advisory = {
|
||||
id?: string;
|
||||
ghsa_id?: string;
|
||||
cve_id?: string | null;
|
||||
status?: string;
|
||||
stale?: boolean;
|
||||
source_feed?: string;
|
||||
severity?: string;
|
||||
type?: string;
|
||||
application?: string | string[];
|
||||
@@ -15,6 +20,10 @@ export type Advisory = {
|
||||
published?: string;
|
||||
updated?: string;
|
||||
affected?: string[];
|
||||
platforms?: string[];
|
||||
references?: string[];
|
||||
nvd_url?: string | null;
|
||||
github_advisory_url?: string;
|
||||
};
|
||||
|
||||
export type FeedPayload = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-suite",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"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": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.3] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
- Documented that the default signed advisory feed is consolidated and may include NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records while Hermes matching remains package-scoped.
|
||||
|
||||
## [0.1.2] - 2026-05-15
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: hermes-attestation-guardian
|
||||
version: 0.1.2
|
||||
version: 0.1.3
|
||||
description: Hermes-only runtime security attestation and drift detection skill for operator-managed Hermes infrastructure.
|
||||
homepage: https://clawsec.prompt.security
|
||||
hermes:
|
||||
@@ -24,7 +24,7 @@ For standalone installs, verify the signed release manifest before trusting `SKI
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_NAME="hermes-attestation-guardian"
|
||||
VERSION="0.1.2"
|
||||
VERSION="0.1.3"
|
||||
REPO="prompt-security/clawsec"
|
||||
TAG="${SKILL_NAME}-v${VERSION}"
|
||||
BASE="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
@@ -207,6 +207,8 @@ Severity messages are emitted as INFO / WARNING / CRITICAL style lines.
|
||||
|
||||
## Advisory feed override knobs
|
||||
|
||||
The default signed advisory feed is consolidated: it can contain NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records. Hermes matching still gates on affected package names and supported version ranges.
|
||||
|
||||
- Source selection: `HERMES_ADVISORY_FEED_SOURCE=auto|remote|local`
|
||||
- Remote artifacts: `HERMES_ADVISORY_FEED_URL`, `HERMES_ADVISORY_FEED_SIG_URL`, `HERMES_ADVISORY_FEED_CHECKSUMS_URL`, `HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL`
|
||||
- Local artifacts: `HERMES_LOCAL_ADVISORY_FEED`, `HERMES_LOCAL_ADVISORY_FEED_SIG`, `HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS`, `HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-attestation-guardian",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Hermes-only runtime security attestation and drift detection skill. Generates deterministic posture artifacts, verifies integrity fail-closed, and classifies baseline drift severity.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.3] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
- Documented that Picoclaw advisory checks consume the consolidated signed advisory feed, including NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
|
||||
|
||||
## [0.0.2] - 2026-05-13
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: picoclaw-security-guardian
|
||||
version: 0.0.2
|
||||
version: 0.0.3
|
||||
description: Picoclaw security posture skill with advisory awareness, configuration drift detection, and supply-chain verification guidance.
|
||||
homepage: https://clawsec.prompt.security
|
||||
author: prompt-security
|
||||
@@ -27,7 +27,7 @@ For standalone installs, verify the signed release manifest before trusting `SKI
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_NAME="picoclaw-security-guardian"
|
||||
VERSION="0.0.2"
|
||||
VERSION="0.0.3"
|
||||
REPO="prompt-security/clawsec"
|
||||
TAG="${SKILL_NAME}-v${VERSION}"
|
||||
BASE="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
@@ -127,6 +127,7 @@ node scripts/check_advisories.mjs --feed ~/.picoclaw/security/clawsec/feed.jso
|
||||
```
|
||||
|
||||
The script filters advisories for `picoclaw`, `ai-gateway`, empty/all-platform advisories, or affected package entries containing `picoclaw`.
|
||||
The expected feed input is the consolidated signed ClawSec advisory feed, so it can contain NVD CVEs, approved community advisories, and provisional GHSA-without-CVE records.
|
||||
|
||||
## Drift protection
|
||||
|
||||
@@ -184,4 +185,3 @@ skills/picoclaw-security-guardian/test/picoclaw_security_guardian_sandbox_regres
|
||||
```
|
||||
|
||||
The regression installs the skill through Picoclaw's own `find_skills` / `install_skill` path from a local ClawHub-compatible registry into an isolated Docker-hosted Picoclaw workspace with isolated `HOME`, `PICOCLAW_HOME`, and `PICOCLAW_WORKSPACE`. It verifies signed release-artifact preflight inputs, confirms Picoclaw's skill loader can list/load the installed skill, then runs the installed copy's profile, drift, advisory fail-closed, advisory filtering, and supply-chain verification paths against Picoclaw-style `config.json` and `launcher-config.json` files.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picoclaw-security-guardian",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"description": "Picoclaw security posture skill with advisory awareness, configuration drift detection, and supply-chain verification guidance.",
|
||||
"author": "prompt-security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -33,9 +33,16 @@ export type CorePlatformSlug = (typeof CORE_PLATFORM_SLUGS)[number];
|
||||
export type AdvisoryPlatformSlug = CorePlatformSlug | (string & {});
|
||||
export type AdvisoryPlatformFilter = 'all' | CorePlatformSlug | 'other';
|
||||
|
||||
// Full advisory type from NVD CVE feed or community reports
|
||||
export type AdvisoryLifecycleStatus = 'active' | 'matured' | 'stale' | (string & {});
|
||||
|
||||
// Full advisory type from NVD CVE feed, provisional GHSA feed, or community reports
|
||||
export interface Advisory {
|
||||
id: string;
|
||||
ghsa_id?: string;
|
||||
cve_id?: string | null;
|
||||
status?: AdvisoryLifecycleStatus;
|
||||
stale?: boolean;
|
||||
source_feed?: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
type: AdvisoryType;
|
||||
title: string;
|
||||
@@ -45,7 +52,9 @@ export interface Advisory {
|
||||
published: string;
|
||||
references?: string[];
|
||||
cvss_score?: number | null;
|
||||
cvss_vector?: string | null;
|
||||
nvd_url?: string;
|
||||
github_advisory_url?: string;
|
||||
platforms?: AdvisoryPlatformSlug[];
|
||||
// Community report fields (source defaults to "Prompt Security Staff" when absent)
|
||||
source?: string;
|
||||
|
||||
Reference in New Issue
Block a user