-
Notifications
You must be signed in to change notification settings - Fork 9
610 lines (579 loc) · 29.4 KB
/
supply-chain-scan.yml
File metadata and controls
610 lines (579 loc) · 29.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
name: Supply chain scan
# Static analysis of every `.github/workflows/*.yml` for the attack classes
# the TeamPCP campaign exploited:
#
# * `pull_request_target` workflows that check out fork-controlled code
# ("PWN request") — what got Aqua Security and Datadog targeted.
# * Actions pinned to mutable tags (e.g. `@v4`) instead of full commit
# SHAs — the surface a tag-poisoning attack repoints.
# * `${{ … }}` expansion of attacker-controlled input in `run:` blocks
# (the branch-name / markdown-filename injection used as the foothold).
# * Excessive `GITHUB_TOKEN` permissions (missing `permissions:` block).
#
# Tooling:
# * `zizmor` (woodruffw/zizmor) — purpose-built audit for the attack
# classes above. Outputs SARIF so findings can be browsed in GitHub
# Security → Code scanning.
# * `actionlint` (rhysd/actionlint) — syntax + schema linter. Catches
# malformed workflows before they ship to the runner.
#
# Triggers cover every change surface that can land a workflow file:
# * `pull_request` so new misconfigs are flagged before merge.
# * `push` to protected branches so the baseline keeps a fresh signal.
# * `schedule` weekly so a previously-clean repo gets re-scanned after
# zizmor's audit set is extended upstream.
on:
pull_request:
branches: [main, v10-rc]
paths:
- '.github/workflows/**'
- '.github/actions/**'
- '.github/dependabot.yml'
- '.github/zizmor.yml'
# `pnpm-lock.yaml` and every `package.json` covers the npm-audit
# job — the only change that introduces or removes an npm
# advisory in our dep tree. Combined with the weekly cron below,
# this means the audit re-runs both on lockfile-touching PRs AND
# weekly (to catch newly-published advisories against an
# unchanged lockfile).
- 'pnpm-lock.yaml'
- '**/package.json'
- '.npmrc'
push:
branches: [main, v10-rc]
paths:
- '.github/workflows/**'
- '.github/actions/**'
- '.github/dependabot.yml'
- '.github/zizmor.yml'
- 'pnpm-lock.yaml'
- '**/package.json'
- '.npmrc'
schedule:
# Mondays 06:30 Europe/Belgrade ≈ 04:30 UTC. Same window as Dependabot
# so any week-over-week drift surfaces in a single review pass.
- cron: '30 4 * * 1'
workflow_dispatch:
# Default to read-only; the zizmor job escalates `security-events: write`
# at the job level only because it's the only step that needs to upload
# SARIF. actionlint stays fully read-only.
permissions:
contents: read
concurrency:
group: supply-chain-scan-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
zizmor:
name: zizmor (GitHub Actions audit)
runs-on: ubuntu-latest
# 15 min ceiling — zizmor's online audits (impostor-commit,
# ref-confusion, known-vulnerable-actions, stale-action-refs) walk
# every released tag of every action via `compare_commits`. With
# ~10 actions and ~30 historical tags each that's hundreds of API
# calls; 5 min was hitting the cap and getting cancelled before the
# plain-format gate could run.
timeout-minutes: 15
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
- name: Install zizmor (hash-pinned PyPI wheel)
# Pin the exact version AND the exact wheel/sdist sha256s so even a
# PyPI compromise (forged release with the same version number)
# cannot land arbitrary code in the runner. `--require-hashes`
# refuses to install any artifact whose hash is not in the
# requirements list; `--no-deps` blocks transitive Python deps from
# bypassing the hash check (zizmor 1.7.0's PyPI metadata declares
# no Python deps anyway, but the flag is defense-in-depth in case
# a future bump introduces one without us updating this list).
#
# Hashes lifted verbatim from
# https://pypi.org/pypi/zizmor/1.7.0/json (.urls[].digests.sha256).
# All wheels relevant to a GitHub-hosted ubuntu-latest runner
# (manylinux/musllinux x86_64/aarch64/armv7) plus the sdist are
# listed so pip can pick the right one without falling open.
# Bumps require recomputing this block — Dependabot does not cover
# pip versions used in this single-line install.
run: |
set -euo pipefail
cat > "${RUNNER_TEMP}/zizmor-requirements.txt" <<'REQ'
zizmor==1.7.0 \
--hash=sha256:a7dd9fa77086836d4fc270372a4fed6273bb92287585388ba258ccd9f59c044f \
--hash=sha256:639d290d5074456542b6e5e275effe9565f88ffb24ef1088102bb7ca118ae7de \
--hash=sha256:8dd087a01ac713b8980af73f294c696ebcaafde38bade9a3773a3f792169c4d7 \
--hash=sha256:489ae4e9085d5aa80b9ae40e118f6e94a52af020cc17dc3942b51835ee02445b \
--hash=sha256:ca8a768db5dd267f985cf25515b99a4d893905fff05f4a45cecfc11dc84e4583 \
--hash=sha256:8320f78cf19a65b3e81794a731d64a155c24bc8614347ed946b066e3411bb9de \
--hash=sha256:4f987f4b81ef740863db629391c55d1e7ad75723fc30325dfde63ab36537d6b0
REQ
pip install --require-hashes --no-deps -r "${RUNNER_TEMP}/zizmor-requirements.txt"
zizmor --version
- name: Resolve zizmor scan targets
# Scan EVERYTHING under `.github/` that can land code on the runner:
# workflows + composite actions + reusable workflows. The previous
# version only scanned `.github/workflows/`, which left
# `.github/actions/` (referenced by the trigger paths above) outside
# the audit surface — a future composite action there would inherit
# the same trust boundary as a workflow but skip the gate. zizmor
# auto-detects workflow + composite-action YAML so passing the
# parent dir is safe; it ignores non-workflow YAML (dependabot,
# zizmor configs).
id: targets
run: |
set -euo pipefail
targets=(.github/workflows)
if [ -d .github/actions ]; then
targets+=(.github/actions)
fi
{
echo "paths<<EOF"
for t in "${targets[@]}"; do echo "${t}"; done
echo "EOF"
} >> "$GITHUB_OUTPUT"
printf 'Scanning: %s\n' "${targets[@]}"
- name: Run zizmor (SARIF output)
# `--persona auditor` enables the strict ruleset (every finding is
# treated as a defect, including informational ones). `--min-severity
# low` keeps the CI gate noisy-on-purpose: an informational finding
# like "this third-party action is not SHA-pinned" is exactly the
# signal TeamPCP exploited and SHOULD block a PR.
#
# `--config .github/zizmor.yml` loads the per-repo policy. The only
# entries are documented exceptions with a tracking note + owner;
# never add a blanket suppression without one.
#
# `GH_TOKEN` is REQUIRED for zizmor's online audits — without it
# the four highest-value checks silently no-op:
# * impostor-commit — detects a SHA pointing at a commit
# not in the action's repo history
# (the TeamPCP attack signature).
# * ref-confusion — branch/tag/SHA ambiguity.
# * known-vulnerable- — maintained CVE list.
# actions
# * stale-action-refs — pin is far behind upstream.
# We use the default GITHUB_TOKEN (contents: read at this job's
# scope is enough for zizmor's read-only API calls).
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ZIZMOR_TARGETS: ${{ steps.targets.outputs.paths }}
run: |
set -euo pipefail
# shellcheck disable=SC2086 # ZIZMOR_TARGETS is a newline list of
# repo-relative paths we control entirely (resolved above); word
# splitting is the desired behaviour for the zizmor argv.
read -r -a TARGETS <<< "$(echo "$ZIZMOR_TARGETS" | tr '\n' ' ')"
zizmor \
--persona auditor \
--min-severity low \
--config .github/zizmor.yml \
--format sarif \
"${TARGETS[@]}" \
> zizmor.sarif
continue-on-error: true
- name: Upload SARIF to GitHub Security
# Skip SARIF upload on forked PRs. GitHub Actions downgrades
# `security-events: write` to read-only on `pull_request` runs
# from forks, so the upload step would 403 with `Resource not
# accessible by integration` and turn an otherwise-clean run
# red. The plain-format `Re-run zizmor as a gate` step below
# still runs on forked PRs and surfaces findings in the run log
# — SARIF persistence in the Security tab is a property of
# base-repo runs (push to main / schedule) anyway.
# `continue-on-error` is a belt for unrelated upload flakes
# (codeql-action retries, large SARIF parse failures, etc).
if: |
always()
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository)
continue-on-error: true
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
sarif_file: zizmor.sarif
category: zizmor
- name: Re-run zizmor as a gate
# The previous run uploaded SARIF unconditionally so the Security tab
# always reflects the latest state. This second invocation re-runs
# with the SAME ruleset but allows the exit code to bubble up so the
# PR is blocked when findings exist. GH_TOKEN re-supplied for the
# same reason as the SARIF step above.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ZIZMOR_TARGETS: ${{ steps.targets.outputs.paths }}
run: |
set -euo pipefail
read -r -a TARGETS <<< "$(echo "$ZIZMOR_TARGETS" | tr '\n' ' ')"
zizmor --persona auditor --min-severity low --config .github/zizmor.yml --format plain "${TARGETS[@]}"
npm-audit:
name: pnpm audit (informational)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: pnpm
# `pnpm install` runs only the lockfile resolution and metadata pass
# we need for `pnpm audit`. We're NOT building anything here — keep
# the audit job fast and independent of the heavy CI matrix.
- name: Install dependencies (lockfile only)
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Run pnpm audit (production deps)
# Informational only for now — the lockfile currently carries a
# known baseline of high + critical advisories (see
# docs/security/SUPPLY_CHAIN_HARDENING.md → Tier 3 §H). Flipping
# this to a hard gate is the second step of that follow-up,
# tracked separately. Output uploads as a job-summary table so
# reviewers can see at a glance whether a PR INTRODUCES a new
# advisory vs. carrying over the baseline.
#
# SECURITY: this step distinguishes three audit outcomes that the
# previous version silently collapsed into a binary "table or
# 'no advisories ≥ high'" rendering. The collapse was a
# false-confidence bug — an empty file, an invalid JSON blob, a
# registry 5xx, or a pnpm 10 `.vulnerabilities`-shape response
# all rendered as a confident "No advisories ≥ high. ✓" badge.
# We now branch on three distinct states:
#
# * clean — pnpm audit exited 0 (no advisories at or
# above the threshold) AND the JSON parsed
# as an object.
# * findings — pnpm audit exited 1 AND we can extract a
# count from either the legacy `.advisories`
# map or the npm v2 / pnpm 10 `.vulnerabilities`
# map. Both shapes are rendered.
# * inconclusive — exit >1, empty file, or non-object JSON.
# This typically means the registry, auth,
# or the network failed. Rendered as a
# `::warning::` annotation so the badge does
# not look clean. Job still succeeds because
# it is informational — see follow-up to
# flip to a hard gate after baseline cleanup.
#
# stderr is preserved (`pnpm-audit.stderr`) so reviewers can see
# the underlying registry / auth / network error on inconclusive
# runs without having to re-run the workflow locally.
continue-on-error: true
run: |
set +e
pnpm audit --audit-level=high --prod --json > pnpm-audit.json 2> pnpm-audit.stderr
STATUS=$?
set -e
inconclusive() {
local reason="$1"
echo "::warning title=pnpm audit inconclusive::${reason}"
{
echo '## pnpm audit (inconclusive)'
echo
echo "**Reason:** ${reason}"
echo
echo '**pnpm audit exit status:** `'"${STATUS}"'`'
if [ -s pnpm-audit.stderr ]; then
echo
echo '<details><summary>stderr (first 200 lines)</summary>'
echo
echo '```'
head -200 pnpm-audit.stderr
echo '```'
echo '</details>'
fi
echo
echo 'A clean pnpm audit run requires a valid registry response. This'
echo 'result does NOT mean the dependency tree is clean — it means the'
echo 'audit did not complete and cannot certify either outcome. Investigate'
echo 'before treating this run as evidence.'
} >> "$GITHUB_STEP_SUMMARY" || true
}
# pnpm follows npm semantics:
# 0 = no vulnerabilities at/above threshold
# 1 = vulnerabilities found at/above threshold
# >1 = operational failure (auth, network, registry 5xx, …)
if [ "${STATUS}" -gt 1 ]; then
inconclusive "pnpm audit exited ${STATUS} (>1 = operational failure)"
exit 0
fi
if [ ! -s pnpm-audit.json ]; then
inconclusive 'pnpm audit produced no output file or an empty file'
exit 0
fi
if ! jq -e 'type == "object"' pnpm-audit.json >/dev/null 2>&1; then
inconclusive 'pnpm audit output is not a valid JSON object'
exit 0
fi
# COUNT = high/critical entries, supporting BOTH report shapes:
# * legacy pnpm/npm v1 shape: `.advisories` map keyed by id
# with .severity at the value level.
# * npm v2 / pnpm 10 shape: `.vulnerabilities` map keyed by
# package name with .severity at the value level.
# If neither key is present in a syntactically-valid object,
# treat the run as clean (the schema can legitimately be `{}`
# when there are zero findings on either path).
COUNT="$(
jq '
def sev: . == "high" or . == "critical";
if (has("vulnerabilities") and (.vulnerabilities | type == "object")) then
[.vulnerabilities | to_entries[] | select(.value.severity | sev)] | length
elif (has("advisories") and (.advisories | type == "object")) then
[.advisories | to_entries[] | .value | select(.severity | sev)] | length
else
0
end
' pnpm-audit.json
)"
if [ "${STATUS}" -eq 0 ] && [ "${COUNT:-0}" -eq 0 ]; then
{
echo '## pnpm audit (high + critical, production deps)'
echo
echo 'No advisories ≥ high. ✓'
echo
echo '_Validated: pnpm audit exited 0 and the JSON report parsed as an object with zero matching entries._'
} >> "$GITHUB_STEP_SUMMARY" || true
echo "pnpm audit exit status: ${STATUS} — clean"
exit 0
fi
# Edge case: pnpm exited 1 (= findings ≥ threshold exist) but
# our jq extraction returned 0. That means a FUTURE pnpm
# version emitted findings under a key our `if has(...)`
# chain does not yet recognise. Rendering "Findings: 0"
# here would be exactly the false-confidence pattern this
# whole step exists to prevent. Treat as inconclusive and
# leave a copy of the raw JSON in the summary so a human
# can adjudicate.
if [ "${STATUS}" -eq 1 ] && [ "${COUNT:-0}" -eq 0 ]; then
# shellcheck disable=SC2016
# The single-quoted strings below contain Markdown backticks
# (e.g. `.advisories`, `supply-chain-scan.yml`) used as
# code-fence markers in the GitHub Step Summary output.
# Shellcheck flags backticks-inside-single-quotes as a
# possible missed command substitution; here it's intentional.
{
echo '## pnpm audit (inconclusive — schema mismatch)'
echo
echo 'pnpm audit reported findings (exit 1) but neither the'
echo 'legacy `.advisories` nor the npm v2 `.vulnerabilities`'
echo 'shape contains entries matching the high/critical filter.'
echo 'A new schema may have been introduced upstream. Update'
echo 'the jq extraction in `supply-chain-scan.yml` to recognise'
echo 'it before treating this run as clean.'
echo
echo '<details><summary>raw pnpm-audit.json (first 200 lines)</summary>'
echo
echo '```json'
head -200 pnpm-audit.json
echo '```'
echo '</details>'
} >> "$GITHUB_STEP_SUMMARY" || true
echo "::warning title=pnpm audit schema mismatch::pnpm exited 1 but neither known schema produced findings; refusing to report clean. See job summary for the raw report."
echo "pnpm audit exit status: ${STATUS} — inconclusive (schema mismatch)"
exit 0
fi
# If we got here the report has findings. Render whichever
# schema is present (or both, if a future pnpm version emits
# mixed output). Each branch is null-safe and falls open to
# an empty table on a malformed entry rather than crashing.
{
echo '## pnpm audit (high + critical, production deps)'
echo
echo "**Findings: ${COUNT}** • pnpm audit exit status: \`${STATUS}\`"
echo
echo '| Severity | Package | Vulnerable | Patched | Path | Advisory |'
echo '|----------|---------|------------|---------|------|----------|'
# legacy `.advisories` shape
jq -r '
(.advisories // {}) | to_entries[] |
.value |
select(.severity == "high" or .severity == "critical") |
[
(.severity // ""),
(.module_name // ""),
(.vulnerable_versions // ""),
(.patched_versions // ""),
((.findings[0].paths[0] // "" ) | split(">") | .[0]),
("[link](" + (.url // "") + ")")
] | "| " + join(" | ") + " |"
' pnpm-audit.json
# npm v2 / pnpm 10 `.vulnerabilities` shape
jq -r '
(.vulnerabilities // {}) | to_entries[] |
. as $entry |
$entry.value |
select(.severity == "high" or .severity == "critical") |
[
(.severity // ""),
($entry.key),
(.range // ""),
((.fixAvailable // {}) | if type == "object" then (.version // "") else (.|tostring) end),
"",
((.via // []) | (.[0] // {}) | if type == "object" then ("[link](" + (.url // "") + ")") else ("via " + (.|tostring)) end)
] | "| " + join(" | ") + " |"
' pnpm-audit.json
} >> "$GITHUB_STEP_SUMMARY" || true
echo "pnpm audit exit status: ${STATUS} — ${COUNT} high/critical advisor(y|ies) — informational, see job summary"
actionlint:
name: actionlint (workflow syntax + schema)
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Download + verify actionlint release asset
# SECURITY: we deliberately do NOT `curl … | bash` from
# raw.githubusercontent.com here. Even though the upstream
# installer script verifies its own download, fetching the
# *installer* via a mutable tag ref (e.g. `v1.7.6`) and piping
# straight to `bash` reintroduces the exact tag-poisoning class
# this workflow exists to detect — a force-moved tag or a
# compromised upstream branch would give arbitrary code execution
# inside the security scanner. Instead we:
#
# 1. Pin the actionlint version to a specific release.
# 2. Download the release binary asset directly (GitHub release
# assets are bound to the immutable release object, not to
# raw.githubusercontent.com mirrored tree).
# 3. Verify its SHA-256 against the digest published in the same
# release's `actionlint_${VERSION}_checksums.txt`. The digest
# is committed verbatim into THIS workflow file, so any
# retroactive re-upload of the release asset (or any MITM on
# the download path) fails the check and aborts the run.
# 4. Only THEN extract and execute the binary.
#
# Bumps to a newer actionlint require recomputing ACTIONLINT_SHA256
# from the new release's checksums.txt and updating BOTH this
# workflow and the version string. Dependabot does not cover this
# path; bump deliberately via PR.
env:
ACTIONLINT_VERSION: 1.7.6
# sha256 of actionlint_1.7.6_linux_amd64.tar.gz, lifted from
# https://github.com/rhysd/actionlint/releases/download/v1.7.6/actionlint_1.7.6_checksums.txt
ACTIONLINT_SHA256: 5d1a70d9de15fee5371e6f9e20cc29b284e814d6ee1b882f9749e91caf716eba
run: |
set -euo pipefail
ASSET="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl -fsSL --proto '=https' --tlsv1.2 \
-o "${ASSET}" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${ASSET}"
echo "${ACTIONLINT_SHA256} ${ASSET}" | sha256sum --check --status
tar -xzf "${ASSET}" actionlint
./actionlint --version
- name: Run actionlint (with shellcheck)
# `shellcheck` is pre-installed on ubuntu-latest GitHub-hosted
# runners, so actionlint invokes it as a sub-linter automatically
# (no `-shellcheck=` flag). Pre-existing SC2086 quoting issues
# were fixed in this same PR so the gate is clean from the
# outset.
run: ./actionlint -color
scanner-freshness:
name: Scanner pin freshness check
# This job is the answer to PR460-06 / P3 of the security review:
# "Add a documented scheduled check or Dependabot-compatible
# mechanism for scanner versions and hashes." Dependabot does not
# cover scanner versions that are pinned inside workflow YAML as
# env vars (actionlint binary release, hash-pinned PyPI wheels for
# zizmor, the npx-resolved @cyclonedx/cdxgen version). This job
# walks each pin, queries the upstream registry for the latest
# release, and surfaces drift as a job-summary warning. It is
# informational (`continue-on-error: true`) — the goal is to
# surface stale pins, not to block PRs. Bumps still go through a
# normal review PR.
#
# Only runs on the weekly cron + manual dispatch — drift is a
# cadence problem, not a per-PR problem.
runs-on: ubuntu-latest
timeout-minutes: 3
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
permissions:
contents: read
continue-on-error: true
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Compare pinned scanner versions against upstream latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
STALE=0
{
echo '## Scanner pin freshness'
echo
echo '| Scanner | Pinned | Latest upstream | Status |'
echo '|---------|--------|-----------------|--------|'
} >> "$GITHUB_STEP_SUMMARY"
report() {
local name="$1" pinned="$2" latest="$3" status="$4"
echo "| ${name} | \`${pinned}\` | \`${latest}\` | ${status} |" >> "$GITHUB_STEP_SUMMARY"
if [ "${status}" != "✓ current" ]; then
STALE=1
echo "::warning title=Scanner pin stale::${name} pinned to ${pinned}, upstream latest is ${latest}"
fi
}
# actionlint: pinned in this workflow's `actionlint` job as
# ACTIONLINT_VERSION. Parse it back out of THIS file so a
# bump there is automatically the source of truth here.
ACTIONLINT_PINNED=$(grep -E '^\s+ACTIONLINT_VERSION:\s*' "${GITHUB_WORKSPACE}/.github/workflows/supply-chain-scan.yml" | head -1 | sed -E 's/.*ACTIONLINT_VERSION:\s*//; s/[\"'\'' ]//g')
ACTIONLINT_LATEST=$(gh api repos/rhysd/actionlint/releases/latest --jq '.tag_name' | sed 's/^v//')
if [ -n "${ACTIONLINT_PINNED}" ] && [ -n "${ACTIONLINT_LATEST}" ]; then
if [ "${ACTIONLINT_PINNED}" = "${ACTIONLINT_LATEST}" ]; then
report 'actionlint' "${ACTIONLINT_PINNED}" "${ACTIONLINT_LATEST}" '✓ current'
else
report 'actionlint' "${ACTIONLINT_PINNED}" "${ACTIONLINT_LATEST}" '⚠ behind'
fi
else
report 'actionlint' "${ACTIONLINT_PINNED:-?}" "${ACTIONLINT_LATEST:-?}" '⚠ unable to compare'
fi
# zizmor: pinned via `zizmor==<version>` line in the
# heredoc-generated requirements.txt. Parse it back out of
# this workflow file.
ZIZMOR_PINNED=$(grep -E '^\s*zizmor==' "${GITHUB_WORKSPACE}/.github/workflows/supply-chain-scan.yml" | head -1 | sed -E 's/.*zizmor==//; s/\\.*$//; s/[[:space:]]//g')
# PyPI does not need a token for read-only metadata.
ZIZMOR_LATEST=$(curl -fsSL https://pypi.org/pypi/zizmor/json | jq -r '.info.version')
if [ -n "${ZIZMOR_PINNED}" ] && [ -n "${ZIZMOR_LATEST}" ]; then
if [ "${ZIZMOR_PINNED}" = "${ZIZMOR_LATEST}" ]; then
report 'zizmor' "${ZIZMOR_PINNED}" "${ZIZMOR_LATEST}" '✓ current'
else
report 'zizmor' "${ZIZMOR_PINNED}" "${ZIZMOR_LATEST}" '⚠ behind'
fi
else
report 'zizmor' "${ZIZMOR_PINNED:-?}" "${ZIZMOR_LATEST:-?}" '⚠ unable to compare'
fi
# @cyclonedx/cdxgen: pinned via the `npx --yes
# @cyclonedx/cdxgen@<ver>` line in release.yml. Parse it back
# out of that file so the check stays correct when release.yml
# is bumped.
if [ -f "${GITHUB_WORKSPACE}/.github/workflows/release.yml" ]; then
CDXGEN_PINNED=$(grep -oE '@cyclonedx/cdxgen@[^[:space:]"]+' "${GITHUB_WORKSPACE}/.github/workflows/release.yml" | head -1 | sed 's|@cyclonedx/cdxgen@||')
CDXGEN_LATEST=$(curl -fsSL https://registry.npmjs.org/@cyclonedx/cdxgen | jq -r '.["dist-tags"].latest')
if [ -n "${CDXGEN_PINNED}" ] && [ -n "${CDXGEN_LATEST}" ]; then
if [ "${CDXGEN_PINNED}" = "${CDXGEN_LATEST}" ]; then
report '@cyclonedx/cdxgen' "${CDXGEN_PINNED}" "${CDXGEN_LATEST}" '✓ current'
else
report '@cyclonedx/cdxgen' "${CDXGEN_PINNED}" "${CDXGEN_LATEST}" '⚠ behind'
fi
else
report '@cyclonedx/cdxgen' "${CDXGEN_PINNED:-?}" "${CDXGEN_LATEST:-?}" '⚠ unable to compare'
fi
fi
# shellcheck disable=SC2016
# Backticks in the markdown string below are intentional
# (code-fence formatting in the GitHub Step Summary), not
# missed command substitution.
{
echo
if [ "${STALE}" -eq 0 ]; then
echo '_All scanners up to date._'
else
echo '_One or more scanners are behind upstream. Open a bump PR — recompute hash pins where applicable (zizmor PyPI wheels in `supply-chain-scan.yml`, actionlint release `checksums.txt`)._'
fi
} >> "$GITHUB_STEP_SUMMARY"