From 1b676fd42cf8683db1677a10f25d697cfd3862cb Mon Sep 17 00:00:00 2001 From: davida-ps Date: Wed, 10 Jun 2026 17:18:54 +0300 Subject: [PATCH] fix(skills): scan staged payload with SkillSpector (#264) * fix(skills): scan staged payload with skillspector * fix(skills): embed skillspector report in releases * fix(skills): use body path for release notes --- .github/workflows/skill-release.yml | 81 ++++++++++++------- scripts/ci/simulate_skill_tag_release.mjs | 2 +- scripts/test-skill-release-workflow.mjs | 48 +++++++++++ scripts/test-skill-tag-release-simulation.mjs | 31 ++++++- 4 files changed, 132 insertions(+), 30 deletions(-) diff --git a/.github/workflows/skill-release.yml b/.github/workflows/skill-release.yml index 521790c..d9d1224 100644 --- a/.github/workflows/skill-release.yml +++ b/.github/workflows/skill-release.yml @@ -710,7 +710,7 @@ jobs: --source-ref "${HEAD_SHA}" # --- Generate SkillSpector report --- - if ! generate_skillspector_report "${skill_dir}" "${out_assets}/skillspector-report.md"; then + if ! generate_skillspector_report "${inner_dir}" "${out_assets}/skillspector-report.md"; then failures=$((failures + 1)) rm -rf "${staging_dir}" echo "::endgroup::" @@ -1221,7 +1221,7 @@ jobs: --source-ref "$TAG" # --- Generate SkillSpector report --- - generate_skillspector_report "$SKILL_PATH" "release-assets/skillspector-report.md" + generate_skillspector_report "$INNER_DIR" "release-assets/skillspector-report.md" test -s release-assets/skill-card.md test -s release-assets/permissions.json @@ -1403,38 +1403,63 @@ jobs: echo "INSTALL_EOF" } >> "$GITHUB_OUTPUT" + - name: Prepare GitHub release body + env: + SKILL_NAME: ${{ steps.parse.outputs.skill_name }} + VERSION: ${{ steps.parse.outputs.version }} + CHANGELOG: ${{ steps.changelog.outputs.changelog }} + QUICK_INSTALL: ${{ steps.install.outputs.quick_install }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + node -e ' + const { readFileSync, writeFileSync } = require("node:fs"); + const bodyPath = `${process.env.RUNNER_TEMP}/skill-release-body.md`; + const report = readFileSync("release-assets/skillspector-report.md", "utf8").trimEnd(); + const body = [ + `## ${process.env.SKILL_NAME} ${process.env.VERSION}`, + "", + process.env.CHANGELOG || "", + "", + process.env.QUICK_INSTALL || "", + "", + "### SkillSpector Security Report", + "", + report, + "", + `Download the generated release-payload scan: [skillspector-report.md](https://github.com/${process.env.REPO}/releases/download/${process.env.TAG}/skillspector-report.md)`, + "", + "### Verification", + "", + "`checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key.", + "Verify the signature first, then trust hashes from `checksums.json`:", + "```bash", + `curl -sLO https://github.com/${process.env.REPO}/releases/download/${process.env.TAG}/checksums.json`, + `curl -sLO https://github.com/${process.env.REPO}/releases/download/${process.env.TAG}/checksums.sig`, + `curl -sLO https://github.com/${process.env.REPO}/releases/download/${process.env.TAG}/signing-public.pem`, + "openssl base64 -d -A -in checksums.sig -out checksums.sig.bin", + "openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json", + "```", + "", + "### Files", + "", + "See `checksums.json` for the complete file manifest with SHA256 hashes.", + "The zip archive preserves the full directory structure of the skill.", + "", + "---", + "*Released by ClawSec skill distribution pipeline*", + ].join("\n"); + writeFileSync(bodyPath, `${body}\n`); + ' + - name: Create GitHub Release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}" tag_name: ${{ github.ref_name }} files: release-assets/* - body: | - ## ${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }} - - ${{ steps.changelog.outputs.changelog }} - - ${{ steps.install.outputs.quick_install }} - - ### Verification - - `checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key. - Verify the signature first, then trust hashes from `checksums.json`: - ```bash - curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json - curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig - curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem - openssl base64 -d -A -in checksums.sig -out checksums.sig.bin - openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json - ``` - - ### Files - - See `checksums.json` for the complete file manifest with SHA256 hashes. - The zip archive preserves the full directory structure of the skill. - - --- - *Released by ClawSec skill distribution pipeline* + body_path: ${{ runner.temp }}/skill-release-body.md draft: false prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} env: diff --git a/scripts/ci/simulate_skill_tag_release.mjs b/scripts/ci/simulate_skill_tag_release.mjs index 00a88c1..dad8d5d 100644 --- a/scripts/ci/simulate_skill_tag_release.mjs +++ b/scripts/ci/simulate_skill_tag_release.mjs @@ -469,7 +469,7 @@ async function main() { await runSkillSpector({ skillspectorBin: args.skillspectorBin, - skillDir: tempSkillDir, + skillDir: innerDir, reportPath: path.join(releaseAssetsDir, "skillspector-report.md"), }); diff --git a/scripts/test-skill-release-workflow.mjs b/scripts/test-skill-release-workflow.mjs index 5670374..87dfd2b 100644 --- a/scripts/test-skill-release-workflow.mjs +++ b/scripts/test-skill-release-workflow.mjs @@ -88,6 +88,54 @@ assert.match( 'Skill release workflow must generate a SkillSpector report for each released skill', ); +assert.match( + workflow, + /### SkillSpector Security Report[\s\S]*\[skillspector-report\.md\]\(https:\/\/github\.com\/\$\{process\.env\.REPO\}\/releases\/download\/\$\{process\.env\.TAG\}\/skillspector-report\.md\)/, + 'GitHub release notes must include a direct SkillSpector report link', +); + +assert.match( + workflow, + /readFileSync\("release-assets\/skillspector-report\.md", "utf8"\)/, + 'GitHub release notes must load the generated SkillSpector report content into the release body file', +); + +assert.match( + workflow, + /body_path: \$\{\{ runner\.temp \}\}\/skill-release-body\.md/, + 'GitHub release creation must use body_path for the generated release body file', +); + +assert.doesNotMatch( + workflow, + /SKILLSPECTOR_REPORT_EOF|\$\{\{ steps\.skillspector_report\.outputs\.body \}\}|cat release-assets\/skillspector-report\.md[\s\S]*>> "\$GITHUB_OUTPUT"/, + 'SkillSpector report content must not be sent through GitHub Actions step outputs', +); + +assert.match( + workflow, + /generate_skillspector_report "\$\{inner_dir\}" "\$\{out_assets\}\/skillspector-report\.md"/, + 'PR dry-run SkillSpector scan must target the staged release payload, not the source skill directory', +); + +assert.doesNotMatch( + workflow, + /generate_skillspector_report "\$\{skill_dir\}" "\$\{out_assets\}\/skillspector-report\.md"/, + 'PR dry-run SkillSpector scan must not include source-only test directories', +); + +assert.match( + workflow, + /generate_skillspector_report "\$INNER_DIR" "release-assets\/skillspector-report\.md"/, + 'Tag release SkillSpector scan must target the staged release payload, not the source skill directory', +); + +assert.doesNotMatch( + workflow, + /generate_skillspector_report "\$SKILL_PATH" "release-assets\/skillspector-report\.md"/, + 'Tag release SkillSpector scan must not include source-only test directories', +); + assert.match( workflow, /Generate release trust packet/, diff --git a/scripts/test-skill-tag-release-simulation.mjs b/scripts/test-skill-tag-release-simulation.mjs index 0d12166..fb10a75 100644 --- a/scripts/test-skill-tag-release-simulation.mjs +++ b/scripts/test-skill-tag-release-simulation.mjs @@ -94,7 +94,36 @@ try { await writeFile( fakeSkillspector, `#!/usr/bin/env node -import { writeFileSync } from "node:fs"; +import { readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const scanIndex = process.argv.indexOf("scan"); +if (scanIndex === -1 || !process.argv[scanIndex + 1]) { + console.error("missing scan target"); + process.exit(2); +} + +function containsTestDirectory(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const lowerName = entry.name.toLowerCase(); + if (lowerName === "test" || lowerName === "tests") { + return true; + } + if (containsTestDirectory(path.join(dir, entry.name))) { + return true; + } + } + return false; +} + +const scanTarget = process.argv[scanIndex + 1]; +if (containsTestDirectory(scanTarget)) { + console.error("SkillSpector test fixture must scan the staged release payload, not source test directories."); + process.exit(42); +} const outputIndex = process.argv.indexOf("--output"); if (outputIndex === -1 || !process.argv[outputIndex + 1]) {