Skip to content

Commit dd96de5

Browse files
committed
feat(ci): add and test supply-chain hardening in release workflows
GitHub Actions cache poisoning is a [known attack][1] against credential-bearing workflows: - a lower-privileged run plants a malicious entry under a deterministic cache key - a privileged workflow restores a cache from that cache key and executes it - secrets are exfiltrated The `release.yml` workflow publishes packages to npm using OIDC trusted publishing and an `NPM_TOKEN`. Before this change, `release.yml` was vulnerable to this class of attack. Mitigate this by explicitly disabling all build caching in `release.yml`: - `pnpm/action-setup` sets `cache: false` - `actions/setup-node` sets `package-manager-cache: false` - `actions/setup-node` does not set `cache:` - no `actions/cache` step is used Also add a check to the repo (`tests-supply-chain.yml`) to test that high-risk workflows (`release.yml`) are not using caching, and it is not added back in. [1](https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/)
1 parent 8195af7 commit dd96de5

14 files changed

Lines changed: 417 additions & 1 deletion

.github/workflows/release.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,19 @@ jobs:
2626
name: Install pnpm
2727
with:
2828
run_install: false
29+
# Supply-chain hardening — never cache the pnpm store; a poisoned
30+
# cache entry would execute in this credential-bearing workflow.
31+
cache: false
2932

3033
- name: Install Node.js
3134
uses: actions/setup-node@v6
3235
with:
3336
node-version: 22
34-
cache: 'pnpm'
37+
# No `cache:`, and package-manager-cache disabled. release.yml
38+
# publishes to npm (NPM_TOKEN + OIDC) and must not restore the
39+
# GitHub Actions cache — a cache-poisoning / supply-chain vector.
40+
# Enforced by .github/workflows/tests-supply-chain.yml.
41+
package-manager-cache: false
3542

3643
# node-pty's install hook falls back to `node-gyp rebuild` when no
3744
# linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Test supply chain security
2+
3+
# Supply-chain gate: asserts that release.yml (and this workflow) never
4+
# restore the GitHub Actions cache. A workflow that both restores a cache
5+
# and holds publish credentials is a cache-poisoning target — see
6+
# https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/
7+
#
8+
# The check (scripts/lint-no-workflow-caching.mjs) requires caching to be
9+
# disabled *explicitly*, not just left at its default:
10+
# - `pnpm/action-setup` must set `cache: false`
11+
# - `actions/setup-node` must set `package-manager-cache: false`
12+
# - no `cache:` input and no `actions/cache` step anywhere
13+
# See the "CI/CD Supply-Chain Hardening" section of SECURITY.md.
14+
#
15+
# Deliberately minimal:
16+
# - GitHub-hosted runner (no Blacksmith transparent cache)
17+
# - contents:read only
18+
# - no secrets
19+
# - no caching
20+
21+
on:
22+
push:
23+
branches:
24+
- main
25+
paths:
26+
- '.github/workflows/release.yml'
27+
- '.github/workflows/tests-supply-chain.yml'
28+
- 'scripts/lint-no-workflow-caching.mjs'
29+
- 'scripts/__tests__/lint-no-workflow-caching.test.mjs'
30+
- 'scripts/__tests__/fixtures/lint-no-workflow-caching/**'
31+
pull_request:
32+
branches:
33+
- '**'
34+
paths:
35+
- '.github/workflows/release.yml'
36+
- '.github/workflows/tests-supply-chain.yml'
37+
- 'scripts/lint-no-workflow-caching.mjs'
38+
- 'scripts/__tests__/lint-no-workflow-caching.test.mjs'
39+
- 'scripts/__tests__/fixtures/lint-no-workflow-caching/**'
40+
41+
permissions:
42+
contents: read
43+
44+
jobs:
45+
verify-no-caching-in-release-workflows:
46+
name: Verify no caching in release workflows
47+
runs-on: ubuntu-latest
48+
49+
steps:
50+
- name: Checkout Repo
51+
uses: actions/checkout@v6
52+
53+
- uses: pnpm/action-setup@v6
54+
name: Install pnpm
55+
with:
56+
run_install: false
57+
# Do not use caching in this cache-testing workflow
58+
cache: false
59+
60+
- name: Install Node.js
61+
uses: actions/setup-node@v6
62+
with:
63+
node-version: 22
64+
# Do not use caching in this cache-testing workflow
65+
package-manager-cache: false
66+
67+
# node-pty's install hook falls back to `node-gyp rebuild` when no
68+
# linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships
69+
# node-gyp on PATH, so install it explicitly.
70+
- name: Install node-gyp
71+
run: npm install -g node-gyp
72+
73+
- name: Install dependencies
74+
run: pnpm install --frozen-lockfile
75+
76+
- name: Run lint script self-tests
77+
run: pnpm run test:scripts
78+
79+
- name: Verify no caching in release.yml and tests-supply-chain.yml
80+
run: pnpm run lint:workflow-cache

SECURITY.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,28 @@ To maintain a strong security posture, contributors MUST:
135135

136136
---
137137

138+
## CI/CD Supply-Chain Hardening
139+
140+
The `release.yml` workflow publishes packages to npm using OIDC trusted
141+
publishing and an `NPM_TOKEN`. **GitHub Actions cache poisoning** is a known
142+
attack against credential-bearing workflows: a lower-privileged run plants a
143+
malicious entry under a deterministic cache key, and a privileged workflow
144+
restores and executes it — exfiltrating its secrets.
145+
146+
To close this vector, `release.yml` and `.github/workflows/tests-supply-chain.yml`
147+
disable all build caching, *explicitly*:
148+
149+
- `pnpm/action-setup` sets `cache: false`
150+
- `actions/setup-node` sets `package-manager-cache: false`
151+
- no `cache:` input and no `actions/cache` step is used
152+
153+
This is enforced on every pull request by the `tests-supply-chain.yml` workflow,
154+
which runs `scripts/lint-no-workflow-caching.mjs` against both files. Contributors
155+
**must not** re-enable caching in these workflows — doing so downgrades a
156+
security control and will fail the gate.
157+
158+
---
159+
138160
## Questions?
139161

140162
For general questions about CipherStash security practices (not security incidents), contact:

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules",
4949
"code:fix": "biome check --write",
5050
"lint:runners": "node scripts/lint-no-hardcoded-runners.mjs",
51+
"lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs",
5152
"release": "pnpm run build && changeset publish",
5253
"test": "turbo test --filter './packages/*'",
5354
"test:e2e": "turbo run test:e2e",
@@ -57,6 +58,7 @@
5758
"@biomejs/biome": "^1.9.4",
5859
"@changesets/cli": "^2.29.6",
5960
"@types/node": "^22.15.12",
61+
"js-yaml": "^3.14.2",
6062
"rimraf": "^6.1.2",
6163
"turbo": "2.1.1",
6264
"vitest": "catalog:repo"

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Actions Cache Restore
2+
on:
3+
push:
4+
branches: [main]
5+
jobs:
6+
build:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v6
10+
- name: Restore build cache
11+
uses: actions/cache/restore@v4
12+
with:
13+
path: .turbo
14+
key: turbo-${{ github.sha }}
15+
- run: pnpm install --frozen-lockfile
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Actions Cache Save
2+
on:
3+
push:
4+
branches: [main]
5+
jobs:
6+
build:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v6
10+
- name: Save build cache
11+
uses: actions/cache/save@v4
12+
with:
13+
path: .turbo
14+
key: turbo-${{ github.sha }}
15+
- run: pnpm install --frozen-lockfile
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Actions Cache
2+
on:
3+
push:
4+
branches: [main]
5+
jobs:
6+
build:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v6
10+
- name: Restore build cache
11+
uses: actions/cache@v4
12+
with:
13+
path: .turbo
14+
key: turbo-${{ github.sha }}
15+
- run: pnpm install --frozen-lockfile
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Clean
2+
on:
3+
push:
4+
branches: [main]
5+
jobs:
6+
build:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v6
10+
- uses: pnpm/action-setup@v6
11+
with:
12+
run_install: false
13+
cache: false
14+
- name: Install Node.js
15+
uses: actions/setup-node@v6
16+
with:
17+
node-version: 22
18+
package-manager-cache: false
19+
- run: pnpm install --frozen-lockfile
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Missing Explicit False
2+
on:
3+
push:
4+
branches: [main]
5+
jobs:
6+
build:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v6
10+
- uses: pnpm/action-setup@v6
11+
with:
12+
run_install: false
13+
- name: Install Node.js
14+
uses: actions/setup-node@v6
15+
with:
16+
node-version: 22
17+
- run: pnpm install --frozen-lockfile

0 commit comments

Comments
 (0)