diff --git a/README.md b/README.md index f78b5e4..3a3c0a8 100644 --- a/README.md +++ b/README.md @@ -546,8 +546,11 @@ from that point on, OIDC handles everything. After trusted publishing works, set npm's package publishing access to require 2FA and disallow token publishing. Anvil also fails if -`NPM_TOKEN`, `NODE_AUTH_TOKEN`, `NPM_CONFIG_PROVENANCE`, or npm auth -material in `.npmrc` is present during publish. +`NPM_TOKEN`, a real `NODE_AUTH_TOKEN`, `NPM_CONFIG_PROVENANCE`, or +npm auth material in `.npmrc` is present during publish. The literal +`actions/setup-node` placeholders (`NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX` +and `_authToken=${NODE_AUTH_TOKEN}` in the generated `.npmrc`) are +accepted; npm 11.5+ ignores them during OIDC exchange. ### Why the caller-workflow trust model diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index d7fc9b1..3ea132b 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -39,7 +39,7 @@ of the defences listed below, that change needs explicit justification. | Maintainer accidentally pushing the wrong commit | In manual mode, the GitHub Release trigger forces an explicit, reviewable action — the maintainer creates the Release and the action runs only in response. In auto mode, a conventional commit on main is the explicit human action, and the chained workflow (see `docs/design/chained-workflows.md`) runs in one CI run. Either way, a real human commit is the trigger; there is no scheduled or background release path. | | Consumer repo leaking a PAT used to bridge the auto-release → release.yml event chain | Replaced event coupling with `workflow_call` coupling in v0.6. The chained auto-release flow needs no PAT — one workflow run, one `GITHUB_TOKEN`, no long-lived credential in the consumer repo. Legacy `GH_TOKEN` secret is still accepted but silently unused; a stale PAT in a consumer repo no longer weakens anything. | | Unreviewed npm publish after the release workflow starts | The reusable workflow attaches the publish job to the `npm-publish` GitHub Environment by default. Consumers can configure that environment with required reviewers, prevent self-review, and release-ref restrictions, then bind npm trusted publishing to the same environment. | -| Maintainer accidentally leaving legacy npm token publishing enabled in the release job | `publish-npm` fails if `NPM_TOKEN`, `NODE_AUTH_TOKEN`, or npm auth material in `.npmrc` is present. npm publish must go through OIDC trusted publishing, and `publishConfig.provenance: true` is enforced before upload. | +| Maintainer accidentally leaving legacy npm token publishing enabled in the release job | `publish-npm` fails if `NPM_TOKEN` or a real `NODE_AUTH_TOKEN` is set, or if npm auth material is present in `.npmrc`. The literal `actions/setup-node` placeholders (`NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX` and `_authToken=${NODE_AUTH_TOKEN}`) are accepted because they carry no secret material; npm 11.5+ ignores them during OIDC exchange. npm publish must go through OIDC trusted publishing, and `publishConfig.provenance: true` is enforced before upload. | | Supply-chain attack via the action's own transitive dependencies | The action has no Node dependencies. It invokes `bash`, `jq`, `gh`, `npm`, and `sed`/`awk`/`find`/`grep` from the GitHub-managed runner image. No fetched binaries. | | 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. | diff --git a/steps/publish-npm.sh b/steps/publish-npm.sh index 3c498a4..8aedfd7 100755 --- a/steps/publish-npm.sh +++ b/steps/publish-npm.sh @@ -37,12 +37,24 @@ if ! jq -e '.publishConfig.provenance == true' "$pkg" >/dev/null; then fi [[ -z "${NPM_TOKEN:-}" ]] || die "NPM_TOKEN is set; use OIDC trusted publishing, not long-lived npm tokens" -[[ -z "${NODE_AUTH_TOKEN:-}" ]] || die "NODE_AUTH_TOKEN is set; use OIDC trusted publishing, not long-lived npm tokens" +# actions/setup-node writes literal placeholders when registry-url is set +# without a real token: NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX in env, and +# `_authToken=${NODE_AUTH_TOKEN}` in the generated .npmrc. npm 11.5+ ignores +# both during OIDC exchange, but a strict null/auth-material check would +# trip on them. We accept the exact placeholder forms and reject anything +# else. See actions/setup-node src/authutil.ts. +SETUP_NODE_TOKEN_PLACEHOLDER='XXXXX-XXXXX-XXXXX-XXXXX' +if [[ -n "${NODE_AUTH_TOKEN:-}" && "${NODE_AUTH_TOKEN}" != "$SETUP_NODE_TOKEN_PLACEHOLDER" ]]; then + die "NODE_AUTH_TOKEN is set; use OIDC trusted publishing, not long-lived npm tokens" +fi [[ -z "${NPM_CONFIG_PROVENANCE:-}" ]] || die "NPM_CONFIG_PROVENANCE is set; use publishConfig.provenance instead" for npmrc in .npmrc "${NPM_CONFIG_USERCONFIG:-}"; do [[ -n "$npmrc" && -f "$npmrc" ]] || continue - if grep -Eq '(^|[/:])(_authToken|_auth|_password)[[:space:]]*=' "$npmrc"; then + # Allow the literal `${NODE_AUTH_TOKEN}` template form written by + # setup-node; reject any line whose auth value is anything else. + if grep -E '(^|[/:])(_authToken|_auth|_password)[[:space:]]*=' "$npmrc" \ + | grep -vqE '=[[:space:]]*\$\{NODE_AUTH_TOKEN\}[[:space:]]*$'; then die "npm auth material found in $npmrc; remove token auth before publishing" fi done diff --git a/test/publish-npm.bats b/test/publish-npm.bats index 544db4b..66f746c 100644 --- a/test/publish-npm.bats +++ b/test/publish-npm.bats @@ -142,6 +142,40 @@ EOF ! grep -q 'NPM_CALL: publish' "$NPM_LOG" } +@test "publish-npm: refuses real NODE_AUTH_TOKEN" { + command -v jq >/dev/null 2>&1 || skip "jq not available" + + setup_release_fixture "test-pkg" "1.0.0" "sha512-LOCAL" + export NODE_AUTH_TOKEN="npm_realToken123" + + run "$ACTION_ROOT/steps/publish-npm.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"NODE_AUTH_TOKEN is set"* ]] + ! grep -q 'NPM_CALL: publish' "$NPM_LOG" +} + +@test "publish-npm: allows setup-node placeholder NODE_AUTH_TOKEN" { + command -v jq >/dev/null 2>&1 || skip "jq not available" + + setup_release_fixture "test-pkg" "1.0.0" "sha512-LOCAL" + export NODE_AUTH_TOKEN="XXXXX-XXXXX-XXXXX-XXXXX" + + run "$ACTION_ROOT/steps/publish-npm.sh" + [ "$status" -eq 0 ] + grep -q "^NPM_CALL: publish --access public $meta_dir/test-pkg-1.0.0.tgz$" "$NPM_LOG" +} + +@test "publish-npm: allows setup-node template _authToken in npmrc" { + command -v jq >/dev/null 2>&1 || skip "jq not available" + + setup_release_fixture "test-pkg" "1.0.0" "sha512-LOCAL" + printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\nregistry=https://registry.npmjs.org/\n' > "$FIXTURE_DIR/.npmrc" + + run "$ACTION_ROOT/steps/publish-npm.sh" + [ "$status" -eq 0 ] + grep -q "^NPM_CALL: publish --access public $meta_dir/test-pkg-1.0.0.tgz$" "$NPM_LOG" +} + @test "publish-npm: happy path runs dry-run then real publish on the recorded tarball" { command -v jq >/dev/null 2>&1 || skip "jq not available"