scan-plugins.yml 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. name: Scan Plugins
  2. # Claude policy scan of changed external marketplace entries.
  3. #
  4. # `scan` is a required status check on main. A path-filtered workflow never
  5. # reports a check run when its paths don't match, which would leave unrelated
  6. # PRs blocked forever — so this workflow runs on every PR and skips the heavy
  7. # scan setup at the step level when nothing scan-relevant changed. The check
  8. # always reports.
  9. #
  10. # Verdict cache: each (plugin, sha) pair is scanned at most once. The bump
  11. # workflow force-resets bump/plugin-shas every night, which makes the same
  12. # SHAs reappear in the diff on consecutive nights — without a cache, the
  13. # scan would re-burn ~90s of Claude time per entry per night. The cache is
  14. # keyed on the policy hash so a prompt or schema change invalidates all
  15. # verdicts and triggers a clean re-scan.
  16. #
  17. # Failure handling: a cached `passes:false` verdict still fails the job. The
  18. # Revert Failed Bumps workflow (revert-failed-bumps.yml) reacts to that by
  19. # dropping the failing entries from the bump PR, so one bad upstream can't
  20. # block the rest. After the revert, the re-dispatched scan finds only
  21. # cached-pass entries and goes green in seconds.
  22. on:
  23. pull_request:
  24. workflow_dispatch:
  25. inputs:
  26. scan_all:
  27. description: Scan every external entry (full re-review). Slow.
  28. type: boolean
  29. default: false
  30. permissions:
  31. contents: read
  32. # Serialize scans per ref so concurrent runs (a re-dispatch racing the
  33. # original, or a manual dispatch) don't both restore the same cache, scan
  34. # overlapping sets, and lose one another's verdicts on save.
  35. concurrency:
  36. group: scan-plugins-${{ github.event.pull_request.number || github.ref }}
  37. cancel-in-progress: false
  38. env:
  39. MARKETPLACE: .claude-plugin/marketplace.json
  40. CACHE_DIR: ${{ github.workspace }}/.scan-cache
  41. CACHE_TTL_DAYS: '30'
  42. jobs:
  43. scan:
  44. runs-on: ubuntu-latest
  45. timeout-minutes: 360
  46. steps:
  47. - uses: actions/checkout@v4
  48. with:
  49. fetch-depth: 0
  50. # Same paths the workflow-level filter used to gate on. workflow_dispatch
  51. # always runs the scan (no PR diff to inspect).
  52. - name: Check for scan-relevant changes
  53. id: changes
  54. env:
  55. EVENT_NAME: ${{ github.event_name }}
  56. BASE_SHA: ${{ github.event.pull_request.base.sha }}
  57. run: |
  58. set -euo pipefail
  59. if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
  60. echo "relevant=true" >> "$GITHUB_OUTPUT"
  61. echo "base_ref=origin/main" >> "$GITHUB_OUTPUT"
  62. exit 0
  63. fi
  64. echo "base_ref=$BASE_SHA" >> "$GITHUB_OUTPUT"
  65. if git diff --quiet "$BASE_SHA" HEAD -- "$MARKETPLACE" .github/policy/; then
  66. echo "relevant=false" >> "$GITHUB_OUTPUT"
  67. echo "::notice::No changes to marketplace.json or policy/ — skipping policy scan."
  68. else
  69. echo "relevant=true" >> "$GITHUB_OUTPUT"
  70. fi
  71. # The shared action no-ops gracefully when ANTHROPIC_API_KEY is unset
  72. # (sensible default for community repos). Here `scan` is a required
  73. # check, so a silent no-op would make it a rubber stamp — fail closed.
  74. - name: Require ANTHROPIC_API_KEY when a scan is needed
  75. if: steps.changes.outputs.relevant == 'true'
  76. env:
  77. API_KEY_SET: ${{ secrets.ANTHROPIC_API_KEY != '' }}
  78. run: |
  79. if [[ "$API_KEY_SET" != "true" ]]; then
  80. echo "::error::ANTHROPIC_API_KEY is not configured; refusing to skip a required policy scan."
  81. exit 1
  82. fi
  83. # Verdict cache, keyed on the policy content hash. A prompt change
  84. # invalidates every cached verdict — that is intentional. The save key
  85. # includes run_id so each run writes a fresh cache; restore-keys picks
  86. # the most recent one. Verdicts older than CACHE_TTL_DAYS are pruned on
  87. # restore to bound cache size as the marketplace grows.
  88. - name: Restore verdict cache
  89. if: steps.changes.outputs.relevant == 'true'
  90. id: cache-restore
  91. uses: actions/cache/restore@v4
  92. with:
  93. path: .scan-cache
  94. # run_attempt so a re-run can save its own verdicts (cache keys are
  95. # immutable; without it a re-run would silently fail to save).
  96. key: scan-verdicts-${{ hashFiles('.github/policy/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
  97. restore-keys: |
  98. scan-verdicts-${{ hashFiles('.github/policy/**') }}-
  99. # Split the diff into cached (skip) and uncached (scan) entries. The
  100. # cache key is "<name>@<sha>" — a SHA is immutable, so a verdict for a
  101. # given (plugin, sha) is permanent under a fixed policy.
  102. - name: Filter scan targets against cache
  103. if: steps.changes.outputs.relevant == 'true'
  104. id: filter
  105. env:
  106. BASE_REF: ${{ steps.changes.outputs.base_ref }}
  107. SCAN_ALL: ${{ inputs.scan_all || 'false' }}
  108. TTL_DAYS: ${{ env.CACHE_TTL_DAYS }}
  109. run: |
  110. set -euo pipefail
  111. mkdir -p "$CACHE_DIR"
  112. # Initialize / prune the verdict map.
  113. if [[ -f "$CACHE_DIR/verdicts.json" ]] && jq -e 'type == "object"' "$CACHE_DIR/verdicts.json" >/dev/null 2>&1; then
  114. # Drop entries older than TTL. Verdicts are immutable per (plugin, sha)
  115. # but pruning keeps the cache from accumulating forever.
  116. cutoff="$(date -u -d "-${TTL_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)"
  117. jq --arg cutoff "$cutoff" \
  118. 'with_entries(select(.value.scanned_at >= $cutoff))' \
  119. "$CACHE_DIR/verdicts.json" > "$CACHE_DIR/verdicts.json.tmp"
  120. mv "$CACHE_DIR/verdicts.json.tmp" "$CACHE_DIR/verdicts.json"
  121. else
  122. echo '{}' > "$CACHE_DIR/verdicts.json"
  123. fi
  124. # Build the change set: entries in HEAD whose object differs from base.
  125. # scan_all overrides to "every external entry" (full re-review).
  126. if [[ "$SCAN_ALL" == "true" ]]; then
  127. jq -c '[.plugins[] | select(.source | type == "object")]' "$MARKETPLACE" \
  128. > "$CACHE_DIR/changed.json"
  129. else
  130. if git cat-file -e "${BASE_REF}:${MARKETPLACE}" 2>/dev/null; then
  131. git show "${BASE_REF}:${MARKETPLACE}" > "$CACHE_DIR/base.json"
  132. else
  133. echo '{"plugins":[]}' > "$CACHE_DIR/base.json"
  134. fi
  135. jq -c -s \
  136. '(.[0].plugins | map({(.name): .}) | add // {}) as $b
  137. | [.[1].plugins[]
  138. | select(.source | type == "object")
  139. | select(($b[.name] // null) != .)]' \
  140. "$CACHE_DIR/base.json" "$MARKETPLACE" > "$CACHE_DIR/changed.json"
  141. fi
  142. changed_count="$(jq 'length' "$CACHE_DIR/changed.json")"
  143. # Split changed entries into cached vs uncached. A hit requires the
  144. # *whole* source object (repo, sha, path, ref) to match the cached
  145. # entry, not just name@sha — a repo migration or path change with the
  146. # same SHA is different scan content and must miss the cache.
  147. jq -c -s \
  148. '.[0] as $cache
  149. | (.[1] | map(. + {key: (.name + "@" + (.source.sha // "")) })) as $entries
  150. | {
  151. to_scan: [$entries[] | select(($cache[.key].source // null) != .source)],
  152. cached: [$entries[] | select(($cache[.key].source // null) == .source)
  153. | . + {verdict: $cache[.key]}]
  154. }' \
  155. "$CACHE_DIR/verdicts.json" "$CACHE_DIR/changed.json" > "$CACHE_DIR/split.json"
  156. jq -c '.to_scan' "$CACHE_DIR/split.json" > "$CACHE_DIR/to-scan.json"
  157. jq -c '.cached' "$CACHE_DIR/split.json" > "$CACHE_DIR/cached.json"
  158. to_scan_count="$(jq 'length' "$CACHE_DIR/to-scan.json")"
  159. cached_count="$(jq 'length' "$CACHE_DIR/cached.json")"
  160. cached_fail_count="$(jq '[.[] | select(.verdict.passes == false)] | length' "$CACHE_DIR/cached.json")"
  161. # Build a filtered marketplace containing only the uncached entries.
  162. # Passing this as the action's marketplace-path means the action's own
  163. # base diff (which can't resolve a path outside git) falls back to an
  164. # empty base and scans everything in the file — which is exactly the
  165. # to-scan set. Annotations point to the temp file rather than the real
  166. # marketplace, but the per-entry verdicts still land in the artifact
  167. # and the step summary.
  168. jq -c '{plugins: .}' "$CACHE_DIR/to-scan.json" > "$CACHE_DIR/scan-targets.json"
  169. {
  170. echo "changed=$changed_count"
  171. echo "to_scan=$to_scan_count"
  172. echo "cached=$cached_count"
  173. echo "cached_failures=$cached_fail_count"
  174. } >> "$GITHUB_OUTPUT"
  175. echo "::notice::$changed_count changed entrie(s): $cached_count cached ($cached_fail_count failing), $to_scan_count to scan."
  176. - name: Scan uncached entries
  177. if: steps.changes.outputs.relevant == 'true' && steps.filter.outputs.to_scan != '0'
  178. id: scan
  179. # Capture the action's per-entry outputs even when it exits nonzero.
  180. # The verdict (cached + fresh) is what gates the job, not the action's
  181. # exit code, and the revert workflow needs the artifact even on failure.
  182. continue-on-error: true
  183. uses: anthropics/claude-plugins-community/.github/actions/scan-plugins@b277757588871fe55b2620de8c6dfda470e2e9d8
  184. with:
  185. anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
  186. marketplace-path: .scan-cache/scan-targets.json
  187. policy-prompt: .github/policy/prompt.md
  188. fail-on-findings: "true"
  189. claude-cli-version: latest
  190. # Merge fresh verdicts into the cache and assemble this run's full
  191. # verdict set (cached + fresh) for downstream consumers. Runs even when
  192. # the scan step failed so that fail verdicts are also cached — that is
  193. # what lets the revert workflow drop them and what stops the same
  194. # failing SHA from being re-scanned every night.
  195. - name: Merge verdicts and assemble run report
  196. if: steps.changes.outputs.relevant == 'true'
  197. id: report
  198. # The action's `scanned` output travels here via an env var, which is
  199. # subject to the OS argv/envp size limit (~128 KiB on Linux). At ~300
  200. # bytes/entry that is ~400 entries — an order of magnitude above the
  201. # cold-start case, and steady state with the cache is ~10/night. If
  202. # the limit is ever hit the runner fails the step before the script
  203. # runs ("argument list too long") — the right response is to clear
  204. # the cache key and lower max-bumps temporarily. Documented here so
  205. # nobody has to rediscover it.
  206. env:
  207. SCANNED_JSON: ${{ steps.scan.outputs.scanned || '[]' }}
  208. run: |
  209. set -euo pipefail
  210. mkdir -p "$CACHE_DIR"
  211. [[ -f "$CACHE_DIR/cached.json" ]] || echo '[]' > "$CACHE_DIR/cached.json"
  212. [[ -f "$CACHE_DIR/changed.json" ]] || echo '[]' > "$CACHE_DIR/changed.json"
  213. # Defensive: a partial or unparseable action output must not poison
  214. # the cache. Treat it as "scanned nothing".
  215. printf '%s' "$SCANNED_JSON" > "$CACHE_DIR/scanned-raw.json"
  216. if ! jq -e 'type == "array"' "$CACHE_DIR/scanned-raw.json" >/dev/null 2>&1; then
  217. echo "::warning::scan action output is not a valid JSON array — treating as empty."
  218. echo '[]' > "$CACHE_DIR/scanned-raw.json"
  219. fi
  220. # Defense in depth: the scan action runs Claude with Read access over
  221. # a cloned external repo and ANTHROPIC_API_KEY in its process env. A
  222. # successful prompt injection could coerce the model to put key
  223. # material into `summary`/`violations`. The action's own step summary
  224. # already carries that risk; this workflow adds an artifact and a PR
  225. # comment, both public sinks. Scrub any key-shaped token here so it
  226. # never reaches the cache, artifact, or comment.
  227. jq -c '(.. | strings) |= gsub("sk-ant-[A-Za-z0-9_-]{8,}"; "[REDACTED]")' \
  228. "$CACHE_DIR/scanned-raw.json" > "$CACHE_DIR/scanned-raw.json.tmp"
  229. mv "$CACHE_DIR/scanned-raw.json.tmp" "$CACHE_DIR/scanned-raw.json"
  230. now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  231. # The action's `scanned` output has no SHA or source — join it with
  232. # the change set by name to recover both for the cache key + the
  233. # source-equality lookup guard.
  234. jq -c -s --arg now "$now" \
  235. '.[0] as $changed
  236. | (.[1] // []) as $scanned
  237. | ($changed | map({(.name): .source}) | add // {}) as $srcs
  238. | [$scanned[]
  239. | . + {source: ($srcs[.name] // null), sha: ($srcs[.name].sha // ""), scanned_at: $now}]' \
  240. "$CACHE_DIR/changed.json" "$CACHE_DIR/scanned-raw.json" \
  241. > "$CACHE_DIR/fresh.json"
  242. # Merge fresh verdicts into the cache, keyed by name@sha. The
  243. # full source object is stored so a future repo/path change with the
  244. # same SHA fails the lookup guard. summary/violations are model
  245. # output — truncate to bound cache size (the artifact carries the
  246. # full text for the run that produced it).
  247. jq -c -s \
  248. '.[0] + ([.[1][] | select(.sha != "") | {(.name + "@" + .sha): {
  249. source: .source,
  250. passes: .passes,
  251. summary: ((.summary // "") | .[0:300]),
  252. violations: ((.violations // "") | .[0:500]),
  253. scanned_at: .scanned_at
  254. }}] | add // {})' \
  255. "$CACHE_DIR/verdicts.json" "$CACHE_DIR/fresh.json" \
  256. > "$CACHE_DIR/verdicts.json.tmp"
  257. mv "$CACHE_DIR/verdicts.json.tmp" "$CACHE_DIR/verdicts.json"
  258. # The full per-entry verdict for THIS run's diff: cached verdicts
  259. # plus freshly-scanned verdicts. The revert workflow consumes the
  260. # `failed` list to know exactly which SHAs to drop.
  261. jq -c -s \
  262. '(.[0] | map({name, sha: .source.sha, passes: .verdict.passes,
  263. summary: (.verdict.summary // ""),
  264. violations: (.verdict.violations // ""),
  265. source: "cache"}))
  266. + (.[1] | map({name, sha, passes,
  267. summary: (.summary // ""),
  268. violations: (.violations // ""),
  269. source: "scan"}))' \
  270. "$CACHE_DIR/cached.json" "$CACHE_DIR/fresh.json" \
  271. > "$CACHE_DIR/run-verdicts.json"
  272. jq -c '[.[] | select(.passes == false) | .name]' "$CACHE_DIR/run-verdicts.json" \
  273. > "$CACHE_DIR/run-failed.json"
  274. fail_count="$(jq 'length' "$CACHE_DIR/run-failed.json")"
  275. total="$(jq 'length' "$CACHE_DIR/run-verdicts.json")"
  276. {
  277. echo "failed_count=$fail_count"
  278. echo "total=$total"
  279. } >> "$GITHUB_OUTPUT"
  280. # `summary` and `violations` are model-generated text shaped by a
  281. # cloned external repo. Strip markdown control characters AND wrap
  282. # in code spans before they hit a publicly-rendered sink — code
  283. # spans neutralize auto-linked bare URLs that a prompt-injected
  284. # upstream could smuggle in. Stripping backticks first stops a
  285. # breakout from the code span.
  286. {
  287. echo "## Policy scan (with verdict cache)"
  288. echo
  289. echo "Changed entries: ${total} · cached: $(jq 'length' "$CACHE_DIR/cached.json") · scanned fresh: $(jq 'length' "$CACHE_DIR/fresh.json") · failures: ${fail_count}"
  290. echo
  291. if [[ "$total" -gt 0 ]]; then
  292. echo "| Plugin | SHA | Passes | Source | Summary |"
  293. echo "|---|---|---|---|---|"
  294. jq -r 'def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
  295. .[] | "| \(.name) | `\(.sha[0:8])` | \(if .passes then "✅" else "❌" end) | \(.source) | `\(.summary | neutralize | .[0:120])` |"' \
  296. "$CACHE_DIR/run-verdicts.json"
  297. fi
  298. if [[ "$fail_count" -gt 0 ]]; then
  299. echo
  300. echo "### Violations"
  301. jq -r 'def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
  302. .[] | select(.passes == false) | "- **\(.name)** — `\(.violations | neutralize | .[0:500])`"' "$CACHE_DIR/run-verdicts.json"
  303. fi
  304. } >> "$GITHUB_STEP_SUMMARY"
  305. # Used by revert-failed-bumps.yml to know which entries to drop. Always
  306. # uploaded when relevant so the revert workflow can distinguish "scan
  307. # found policy failures" from "scan never ran" (infra error → no revert).
  308. - name: Upload scan verdicts artifact
  309. if: steps.changes.outputs.relevant == 'true'
  310. uses: actions/upload-artifact@v4
  311. with:
  312. name: scan-verdicts
  313. path: |
  314. .scan-cache/run-verdicts.json
  315. .scan-cache/run-failed.json
  316. retention-days: 7
  317. # Save even when the scan failed — fail verdicts are what stop us from
  318. # re-burning Claude time on a known-bad SHA every night.
  319. - name: Save verdict cache
  320. if: always() && steps.changes.outputs.relevant == 'true'
  321. uses: actions/cache/save@v4
  322. with:
  323. path: .scan-cache
  324. key: scan-verdicts-${{ hashFiles('.github/policy/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
  325. # Required-check gate. Fails on either fresh or cached policy failures —
  326. # a known-bad SHA must keep failing until it is reverted or upstream
  327. # fixes it (a new SHA is a new cache key and gets a fresh scan).
  328. - name: Gate on policy verdict
  329. if: steps.changes.outputs.relevant == 'true'
  330. env:
  331. FAILED: ${{ steps.report.outputs.failed_count || '0' }}
  332. SCAN_OUTCOME: ${{ steps.scan.outcome }}
  333. run: |
  334. set -euo pipefail
  335. if [[ "$FAILED" != "0" ]]; then
  336. echo "::error::$FAILED entrie(s) fail policy. See the run summary for verdicts."
  337. exit 1
  338. fi
  339. # The action can also fail without a policy verdict (clone error,
  340. # API error, schema mismatch). With zero parsed failures and a
  341. # nonzero exit, that is an infra error — fail loudly so the revert
  342. # workflow does NOT misread it as "everything passed".
  343. if [[ "$SCAN_OUTCOME" == "failure" ]]; then
  344. echo "::error::Scan step failed without a parseable policy verdict (likely an infra error)."
  345. exit 1
  346. fi