What is NPM package provenance?

security5 min

byLucas Santos

This page was machine translated. Read original / Suggest a fix

When we talk about web development, one of the biggest problems we face is security. Even more so when we’re talking about Node.js development with packages distributed through NPM.

One of the biggest sources of attacks on corporate systems comes through external packages installed without proper care. This has been tested a few times already and it’s still a problem today.

The problem isn’t even downloading the package, it’s having no way to be sure that package came from where you expect or that it was published by whoever you expect. But that’s about to change.

NPM Package Provenance#

Recently NPM announced something new: package provenance. This is going to be the way you can verify that a package was made by whoever claims to have made it, and that it was also published from a trustworthy source.

The goal of provenance isn’t just to sign the code, it’s to create a link between the package and where it came from, and which original artifact generated that package. One way to do it would be to anchor the package to its creator through a private key, so whoever built the package could sign it with the private key and distribute a public key for verification.

However, this approach has a problem, because the point of failure is exactly the key. If it leaks, the whole security model goes down the drain. So they came up with a pretty clever way to validate package ownership instead: anchor the identity to the server it came out of.

This creates a much stronger security point, since you’d need to replicate an entire infrastructure to replicate the package signature, which isn’t nearly as trivial as stealing a key.

What’s the trick?#

To pull this off, they’re using some Sigstore solutions, especially one that accepts OpenID Connect tokens to create certificates with a pretty short lifespan, used only to sign the provenance requests.

And that’s the clever part: for every package publish, a key pair is generated to sign the certificate, and those keys are thrown away and replaced by a validity certificate. Nobody can sign the code anymore, but everyone who has the public certificate can verify it was generated by a trusted source. In other words, nobody holds a key.

Image representing the flow of these operations (Source: NPM)

The second part of this process is verification, done through a service called Rekor, a public transaction log. Basically every provenance ends up there and is fully public for anyone to check. So you can download the log that contains the entire signed CI/CD environment and verify whether it’s genuine with the command:

npm audit signatures

The importance of CI/CD#

For all of this to work, the CI/CD metadata needs to be trustworthy, since the information we’re trying to sign is exactly the place where the package was built.

The information I’m talking about is, for example, unique data about the container where the CI process started, like hostnames, environment variables and so on. In the case of GitHub Actions, this is the provenance document that gets signed:

_type: https://in-toto.io/Statement/v0.1
subject:
- name: pkg:npm/sigstore@1.2.0
digest:
sha512: 16bf7e5b59e40522190a425047b8c39ffcc8d145cdb15a69fbb9834240a764e2311bda7ac8d5c1c7dc67b47b1f532607139e570e4915577fab61bae4cc079eb0
predicateType: https://slsa.dev/provenance/v0.2
predicate:
buildType: https://github.com/npm/cli/gha/v2
builder:
id: https://github.com/actions/runner
invocation:
configSource:
uri: git+https://github.com/sigstore/sigstore-js@refs/heads/main
digest:
sha1: 5b8c0801d1f5d105351a403f58c38269de93f680
entryPoint: ".github/workflows/release.yml"
environment:
GITHUB_EVENT_NAME: push
GITHUB_REF: refs/heads/main
GITHUB_REPOSITORY: sigstore/sigstore-js
GITHUB_REPOSITORY_ID: '495574555'
GITHUB_REPOSITORY_OWNER_ID: '71096353'
GITHUB_RUN_ATTEMPT: '1'
GITHUB_RUN_ID: '4503589496'
GITHUB_SHA: 5b8c0801d1f5d105351a403f58c38269de93f680
GITHUB_WORKFLOW_REF: sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main
GITHUB_WORKFLOW_SHA: 5b8c0801d1f5d105351a403f58c38269de93f680
materials:
- uri: git+https://github.com/sigstore/sigstore-js@refs/heads/main
digest:
sha1: 5b8c0801d1f5d105351a403f58c38269de93f680

To generate your own provenance file, NPM created a flag called --provenance, so all you need to do is run npm publish --provenance and your package gets signed.Take a look at the docs too, there’s a lot of interesting stuff there

But then comes the question: why pull data from CI? Simply because it’s not easy to tamper with that data. Mainly because even if you try to mess with the machine’s process by injecting a child_process, well-known servers like GitHub’s will still hold information you can’t strip out, like IP addresses, for example. That way NPM can cross-check the data in the submitted provenance against the data GitHub itself returns.

Because of this, we can’t generate provenances locally, meaning you won’t be able to use npm publish --provenance on your local machine, because unlike GitHub, your machine isn’t a trustworthy data source.

What happens locally?#

Socket published a pretty interesting article about how the package installation or publishing process works. I’ll try to sum it up here.

When you publish a package with provenance:

  1. The package gets compressed into a .tar
  2. NPM verifies your credentials and access token
  3. The package gets sent to your registry as usual
  4. The registry fetches the metadata from the connection (not from the package)
  5. NPM obtains a certificate to sign your package, populated with the information GitHub provides straight from their infrastructure
  6. NPM uses that certificate to create a unique signature for the tarball
  7. That signature gets registered in Rekor
  8. The tarball gets saved to the registry
  9. NPM includes all the metadata and the Rekor location inside the package at https://registry.npmjs.org/$PACKAGE/$VERSION#dist

When you install a package that has a provenance:

  1. NPM fetches the package metadata
  2. Downloads the package signature (the checksum)
  3. Downloads the provenance registration info from Rekor
  4. Downloads the package
  5. Tries to match the checksum against the downloaded checksum
  6. Tries to match the Rekor metadata against the previously downloaded data

One package you can check this on is Socket’s own package, just by going to the link https://registry.npmjs.org/-/npm/v1/attestations/@socketsecurity%2Fcli@0.5.1

There, you can drill into the document until you find attestations[predicateType === https://slsa.dev/provenance/v0.2].bundle.verificationMaterial.tlogEntries[0].logIndex, which is a log number you can check on Rekor.

There you can scroll down to the Data section and see that all the CI-related metadata is right there:

Conclusion#

The provenance process is pretty interesting on its own, and it’s an excellent way to protect the package chain that’s essential to modern development, but it’s also one of the easiest ways for someone to get hacked.

Right now, only GitHub Actions has the verification needed to get a provenance, but, according to NPM, other CIs are being added.