diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d72ac89..3877e5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 5ab398f..410b7e8 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -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 diff --git a/.github/workflows/pages-verify.yml b/.github/workflows/pages-verify.yml index 72f766f..383c1d7 100644 --- a/.github/workflows/pages-verify.yml +++ b/.github/workflows/pages-verify.yml @@ -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 diff --git a/.github/workflows/poll-ghsa-without-cve.yml b/.github/workflows/poll-ghsa-without-cve.yml new file mode 100644 index 0000000..10282b9 --- /dev/null +++ b/.github/workflows/poll-ghsa-without-cve.yml @@ -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 diff --git a/.github/workflows/poll-nvd-cves.yml b/.github/workflows/poll-nvd-cves.yml index fe8bbbc..3dce8fc 100644 --- a/.github/workflows/poll-nvd-cves.yml +++ b/.github/workflows/poll-nvd-cves.yml @@ -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" <> $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 diff --git a/.github/workflows/skill-release.yml b/.github/workflows/skill-release.yml index fa80028..18ba701 100644 --- a/.github/workflows/skill-release.yml +++ b/.github/workflows/skill-release.yml @@ -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 diff --git a/advisories/ghsa-without-cve.json b/advisories/ghsa-without-cve.json new file mode 100644 index 0000000..da512f1 --- /dev/null +++ b/advisories/ghsa-without-cve.json @@ -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": [] +} diff --git a/scripts/ci/test_verify_skill_release_import_closure.py b/scripts/ci/test_verify_skill_release_import_closure.py index 6a4746f..25ea77c 100644 --- a/scripts/ci/test_verify_skill_release_import_closure.py +++ b/scripts/ci/test_verify_skill_release_import_closure.py @@ -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() diff --git a/scripts/ci/verify_skill_release_import_closure.py b/scripts/ci/verify_skill_release_import_closure.py index 6dce63d..b58377b 100755 --- a/scripts/ci/verify_skill_release_import_closure.py +++ b/scripts/ci/verify_skill_release_import_closure.py @@ -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\.{1,2}/[^'\"]+)['\"]", + r"['\"](?P(?:\.{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() diff --git a/scripts/ghsa-without-cve-feed.mjs b/scripts/ghsa-without-cve-feed.mjs new file mode 100644 index 0000000..aa8ea8f --- /dev/null +++ b/scripts/ghsa-without-cve-feed.mjs @@ -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); + }); +} diff --git a/scripts/test-deploy-pages-checksums.mjs b/scripts/test-deploy-pages-checksums.mjs new file mode 100644 index 0000000..da21e2c --- /dev/null +++ b/scripts/test-deploy-pages-checksums.mjs @@ -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", +); diff --git a/scripts/test-ghsa-poll-workflow.mjs b/scripts/test-ghsa-poll-workflow.mjs new file mode 100644 index 0000000..11ca457 --- /dev/null +++ b/scripts/test-ghsa-poll-workflow.mjs @@ -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', +); diff --git a/scripts/test-ghsa-without-cve-feed.mjs b/scripts/test-ghsa-without-cve-feed.mjs new file mode 100644 index 0000000..91a9771 --- /dev/null +++ b/scripts/test-ghsa-without-cve-feed.mjs @@ -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']], + ); +}); diff --git a/scripts/test-nvd-ghsa-consolidation-workflow.mjs b/scripts/test-nvd-ghsa-consolidation-workflow.mjs new file mode 100644 index 0000000..2ac8087 --- /dev/null +++ b/scripts/test-nvd-ghsa-consolidation-workflow.mjs @@ -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'); diff --git a/scripts/test-nvd-ghsa-pipeline-dry-run.mjs b/scripts/test-nvd-ghsa-pipeline-dry-run.mjs new file mode 100644 index 0000000..e1a769c --- /dev/null +++ b/scripts/test-nvd-ghsa-pipeline-dry-run.mjs @@ -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.`, +); diff --git a/scripts/test-skill-release-workflow.mjs b/scripts/test-skill-release-workflow.mjs new file mode 100644 index 0000000..7423313 --- /dev/null +++ b/scripts/test-skill-release-workflow.mjs @@ -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', +); diff --git a/skills/clawsec-feed/CHANGELOG.md b/skills/clawsec-feed/CHANGELOG.md index 4d771d8..7caead3 100644 --- a/skills/clawsec-feed/CHANGELOG.md +++ b/skills/clawsec-feed/CHANGELOG.md @@ -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 diff --git a/skills/clawsec-feed/SKILL.md b/skills/clawsec-feed/SKILL.md index 18d09bc..a474dca 100644 --- a/skills/clawsec-feed/SKILL.md +++ b/skills/clawsec-feed/SKILL.md @@ -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` | --- diff --git a/skills/clawsec-feed/skill.json b/skills/clawsec-feed/skill.json index ba2d9c8..4847bc6 100644 --- a/skills/clawsec-feed/skill.json +++ b/skills/clawsec-feed/skill.json @@ -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", diff --git a/skills/clawsec-nanoclaw/CHANGELOG.md b/skills/clawsec-nanoclaw/CHANGELOG.md index 7f06410..695bafb 100644 --- a/skills/clawsec-nanoclaw/CHANGELOG.md +++ b/skills/clawsec-nanoclaw/CHANGELOG.md @@ -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 diff --git a/skills/clawsec-nanoclaw/SKILL.md b/skills/clawsec-nanoclaw/SKILL.md index afeda05..1e393c2 100644 --- a/skills/clawsec-nanoclaw/SKILL.md +++ b/skills/clawsec-nanoclaw/SKILL.md @@ -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}" diff --git a/skills/clawsec-nanoclaw/lib/types.ts b/skills/clawsec-nanoclaw/lib/types.ts index bb6afe0..58433f3 100644 --- a/skills/clawsec-nanoclaw/lib/types.ts +++ b/skills/clawsec-nanoclaw/lib/types.ts @@ -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; diff --git a/skills/clawsec-nanoclaw/skill.json b/skills/clawsec-nanoclaw/skill.json index 20fadb2..27a04f3 100644 --- a/skills/clawsec-nanoclaw/skill.json +++ b/skills/clawsec-nanoclaw/skill.json @@ -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", diff --git a/skills/clawsec-suite/CHANGELOG.md b/skills/clawsec-suite/CHANGELOG.md index 322c991..6d52ef7 100644 --- a/skills/clawsec-suite/CHANGELOG.md +++ b/skills/clawsec-suite/CHANGELOG.md @@ -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 diff --git a/skills/clawsec-suite/SKILL.md b/skills/clawsec-suite/SKILL.md index bc0cbd4..a7358ca 100644 --- a/skills/clawsec-suite/SKILL.md +++ b/skills/clawsec-suite/SKILL.md @@ -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` diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts index c36974f..96ff239 100644 --- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts @@ -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 = { diff --git a/skills/clawsec-suite/skill.json b/skills/clawsec-suite/skill.json index bdc748c..8a8e5a6 100644 --- a/skills/clawsec-suite/skill.json +++ b/skills/clawsec-suite/skill.json @@ -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", diff --git a/skills/hermes-attestation-guardian/CHANGELOG.md b/skills/hermes-attestation-guardian/CHANGELOG.md index f8e6b18..cc01418 100644 --- a/skills/hermes-attestation-guardian/CHANGELOG.md +++ b/skills/hermes-attestation-guardian/CHANGELOG.md @@ -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 diff --git a/skills/hermes-attestation-guardian/SKILL.md b/skills/hermes-attestation-guardian/SKILL.md index 87af9b6..e216353 100644 --- a/skills/hermes-attestation-guardian/SKILL.md +++ b/skills/hermes-attestation-guardian/SKILL.md @@ -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` diff --git a/skills/hermes-attestation-guardian/skill.json b/skills/hermes-attestation-guardian/skill.json index 05a8e44..cd72ed8 100644 --- a/skills/hermes-attestation-guardian/skill.json +++ b/skills/hermes-attestation-guardian/skill.json @@ -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", diff --git a/skills/picoclaw-security-guardian/CHANGELOG.md b/skills/picoclaw-security-guardian/CHANGELOG.md index e1c2d18..4ab12fe 100644 --- a/skills/picoclaw-security-guardian/CHANGELOG.md +++ b/skills/picoclaw-security-guardian/CHANGELOG.md @@ -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 diff --git a/skills/picoclaw-security-guardian/SKILL.md b/skills/picoclaw-security-guardian/SKILL.md index ede2dc1..9f2d4bf 100644 --- a/skills/picoclaw-security-guardian/SKILL.md +++ b/skills/picoclaw-security-guardian/SKILL.md @@ -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. - diff --git a/skills/picoclaw-security-guardian/skill.json b/skills/picoclaw-security-guardian/skill.json index a8183c6..0564e0e 100644 --- a/skills/picoclaw-security-guardian/skill.json +++ b/skills/picoclaw-security-guardian/skill.json @@ -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", diff --git a/types.ts b/types.ts index 174150b..7b5ae2f 100644 --- a/types.ts +++ b/types.ts @@ -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;