Skip to content

npm trusted publishing auto-enables provenance and discards the --provenance-file bundle (dogfood 4 read-back failure) #97

Description

@yunseo-kim

Observation (live, fourth dogfood)

During the fourth M1 dogfood (vers-js run 31840088262, evidence recorded in #30), the publish step failed closed at npm.publish.readback with windlass.verify.error.registry-linkage-mismatch. The registry mutation itself succeeded, but the slsa.dev/provenance/v1 attestation visible on the registry is an npm-CLI-generated provenance statement, not the Windlass-signed Statement produced and verified by the sign job.

Byte-level comparison of the two statements:

Field Windlass signed bundle (preserved artifact js-ts-npm-provenance-bundle-31840088262-1, sha256 354f4c61…) Registry-published bundle
subject digests sha256 and sha512 sha512 only
externalParameters 10 keys incl. source.* (#81 fields all correct) workflow only, no source.*
internalParameters {} {"github":{…}}
builder.id reusable workflow @5964b19c… (full SHA pin) https://github.com/actions/runner/github-hosted

Root cause (pinned to npm CLI source, npm/cli@51c2bf8)

  1. npm OIDC trusted publishing auto-enables provenance when the config is at its default: lib/utils/oidc.js#L144-L163 sets opts.provenance = true when isDefaultProvenance && !ciInfo.CIRCLE.
  2. In libnpmpublish/lib/publish.js buildMetadata() (L103-L181), provenance attachment is governed by two separate if statements:
    // Outer if — decides whether any provenance bundle is attached at all (has no else).
    if (provenance === true || provenanceFile) {
      ...
      // Inner if — selects the bundle SOURCE.
      if (provenance === true) {
        provenanceBundle = await generateProvenance([subject], opts)   // npm generates its own
      } else {
        provenanceBundle = await verifyProvenance(subject, provenanceFile)  // verify & upload the supplied file
      }
      root._attachments[provenanceBundleName] = { ... }  // whichever bundle was selected is uploaded
    }
    The outer if (provenance === true || provenanceFile) evaluates fine in every configuration. The defect is the inner branch selection: with auto-enabled provenance === true, the inner if (provenance === true) branch is taken and generateProvenance() produces npm's own statement; the inner elseverifyProvenance(subject, provenanceFile), the only path that consumes the --provenance-file bundle — never executes. The supplied Windlass bundle is silently discarded and npm's generated bundle is uploaded in its place.
  3. Our publish path (internal/npmprofile/publish.go:218-227) passes --provenance-file but never disables automatic provenance. publishEnvironment (publish.go:543-564) filters inherited NPM_CONFIG_* variables, which leaves provenance at its default and thereby satisfies the auto-enable condition, forcing the inner provenance === true branch described above.
  4. The read-back then behaved exactly as designed: npm's statement carries only one subject digest, failing matchesPublishedBundle (publish.go:379-394, exactly-two-digests requirement), so no valid Windlass candidate was found and the run classified PublishForeignConflictregistry-linkage-mismatch (publish.go:169-170).

Why this is a bug, not a new decision

The normative requirement already exists:

  • docs/architecture/js-ts-npm-provenance-publish.md:1439-1441: “The profile must not use npm's automatic provenance generation.”
  • ADR 0029 (lines 102-115): the profile must fail rather than silently fall back to npm automatic provenance.

The implementation never enforced the disabling mechanism because npm's trusted-publishing auto-enable was not known when the publish path was specified. Per the dogfood review this observation is recorded here rather than as an ADR: it is not an architecture decision, it is an external-platform behavior pin plus an implementation defect against an existing spec clause.

Fix requirement

The publish invocation must explicitly disable npm automatic provenance whenever --provenance-file is supplied, so that the inner branch reaches verifyProvenance(subject, provenanceFile). Supported mechanism (npm documents provenance and provenance-file as mutually exclusive configuration): set NPM_CONFIG_PROVENANCE=false in the isolated publish environment, or write provenance=false into the isolated npm user/global config that publishEnvironment already provisions. No registry POST /-/npm/v1/attestations upload API exists; --provenance-file remains the only supported external-bundle path (precedent: slsa-github-generator Node.js publish action).

Acceptance

  • Publish argv/environment carries an explicit provenance-disable that survives npm's OIDC auto-enable (unit test asserting the constructed environment/argv, plus a fixture or integration assertion that npm receives provenance=false).
  • Read-back behavior unchanged (semantic binding per spec); the digest-count rejection that caught this live remains covered by tests.
  • After merge: caller pin bump, then dogfood retry with a new version v0.1.3 (npm policy: an unpublished version number can never be reused — 0.1.2 stays published to preserve the Phase 6.4 foreign-conflict test target).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions