1 · Registry metadata
GET registry.npmjs.org/sigstore/5.0.0
2 · The bytes you install
sha512sum sigstore-5.0.0.tgz
3 · Sigstore bundle (DSSE envelope)
4 · in-toto Statement v1 · slsa.dev/provenance/v1
SLSA is an OpenSSF framework that grades how hard it is to tamper with a software artifact between its source code and its release.
Also known as Supply-chain Levels for Software Artifacts, Supply Chain Levels for Software Artifacts, SLSA framework, SLSA provenance, salsa
SLSA (Supply-chain Levels for Software Artifacts, pronounced "salsa") is an OpenSSF framework that defines levels of assurance that a software artifact was built from the claimed source by the claimed build process. Its core mechanism is signed provenance generated by the build platform. SLSA proves where an artifact came from, not whether its code is safe.
That distinction is the whole story of SLSA in 2026. In the TanStack compromise and the @7nohe/openapi-react-query-codegen compromise, attackers published malware to npm with valid SLSA provenance. They did not forge anything. They ran their code inside the legitimate release pipeline, and the provenance accurately named that pipeline.
The most important fact for npm users is this: npm's built-in provenance is SLSA Build Level 2, not Level 3. The SLSA project says so itself. Level 2 means a hosted build platform signed the provenance. It does not mean the signing identity was out of reach of the code being built. Our position: SLSA is the right model for tamper evidence between a commit and a registry, it is necessary, and the 2026 npm worms show exactly where it stops. Treat a provenance badge as a statement about origin, then analyze what is actually in the package.
SLSA is a specification that splits supply chain attack resistance into tracks and grades each track in levels. A build platform that meets a level generates provenance: a signed statement that an artifact with a specific digest was produced by a specific builder, from a specific source, using a specific build definition. A consumer verifies that statement against what it expects for that package.
SLSA v1.0 was released on April 19, 2023 with a single track, Build. SLSA v1.1 was approved in April 2025. SLSA v1.2, announced on November 24, 2025, added the Source track and is still the current version as of October 2026. A Build Environment track and a Dependency track are in development but not part of the spec.
SLSA does not inspect code. Nothing in any level asks whether a function exfiltrates credentials or whether a dependency is a typosquat. SLSA's own threat model is explicit about dependencies: "This version of SLSA does not explicitly address dependency threats, but we expect that a future version will" (SLSA v1.2 threats).
A package registry stores whatever bytes an authenticated publisher uploads. When you run npm publish, the CLI packs a tarball on your machine or CI runner and sends it to the registry. The registry checks that the credential belongs to a maintainer, computes an integrity hash of the uploaded bytes, and serves them. Nothing in that exchange ties the tarball to a commit.
That gap is the trust assumption SLSA attacks. A reader auditing the GitHub repository sees one set of files. The registry can serve a different set, and before provenance nobody could prove which commit, if any, produced it. Provenance closes the gap by moving the description of the build from the publisher to the build platform. The publisher can still lie in a commit message. The platform reports what it ran.
SLSA's threat model labels the points where an attacker can intervene, from (A) the producer to (I) the consumer's usage. The Build track targets the middle of that chain.
| Threat (SLSA label) | Example | Covered by |
|---|---|---|
| (A) Producer | A maintainer publishes malware on purpose | Nothing in SLSA |
| (B) Modifying the source | An unreviewed commit lands on a release branch | Source track |
| (C) Source code management | An admin rewrites branch protection | Source track, partly |
| (D) External build parameters | A build runs from an unofficial fork or ref | Build track, via expectations |
| (E) Build process | One build tampers with another build's output | Build L3 |
| (F) Artifact publication | A properly built package is modified, then uploaded | Build L1 and up, via digest check |
| (G) Distribution channel | A mirror serves altered bytes | Build track, via digest check |
| (H) Package selection | Dependency confusion or typosquatting | Build track for dependency confusion only |
| (I) Usage | The consumer runs the software unsafely | Nothing in SLSA |
The (H) row is narrower than it looks. Provenance that names an expected source repository defeats dependency confusion, because a public look-alike package cannot carry provenance from your private repository. SLSA does not address typosquatting at all.
SLSA Build levels grade how hard it is to forge an artifact's provenance or tamper with the artifact during its build. The levels are cumulative. The requirements below come from the v1.2 build requirements.
| Level | What it adds | What it stops | What it does not stop |
|---|---|---|---|
| Build L0 | Nothing | Nothing | Anything |
| Build L1 | The platform generates provenance naming the builder, build process, and top-level inputs | Release mistakes, such as building from a commit that is not in the upstream repository | Forgery: L1 provenance can be unsigned or self-generated |
| Build L2 | Builds run on a hosted platform, and the platform's control plane signs the provenance | Tampering after the build, and uploads from a laptop posing as CI | Code running inside the build that influences the output or reaches the signing identity |
| Build L3 | Builds are isolated from each other, caches cannot be poisoned across builds, and signing secrets are unreachable from build steps | One build or tenant forging provenance or poisoning another build's inputs | Malicious source, malicious dependencies, or a release workflow that is unsafe by design |
The L3 requirement that matters most is one sentence in the spec: signing material "MUST NOT be accessible to the environment running the user-defined build steps." The same section requires that one build cannot "inject false entries into a build cache used by another build."
SLSA v1.0 deliberately dropped the Level 4 and hermetic-build requirements from v0.1 and deferred them to a future version. There is no Build L4 in v1.2.
The fastest way to understand SLSA is to take one real attestation apart. We used sigstore@5.0.0, published to npm on June 1, 2026 from the sigstore-js repository. Every command below runs against the public registry with no authentication.
The registry's version document points to the attestations and records the tarball's integrity hash:
$ curl -s https://registry.npmjs.org/sigstore/5.0.0 | jq '.dist | {integrity, tarball, attestations}'
{
"integrity": "sha512-hJqJfoG/e4qFQaauQL00c6J6FrHLBGKtkFvW3JbTSIEFOhLrSjdSM/gWd/yUOfYo/gsERehTXGC1VZWX+9X4Dg==",
"tarball": "https://registry.npmjs.org/sigstore/-/sigstore-5.0.0.tgz",
"attestations": {
"url": "https://registry.npmjs.org/-/npm/v1/attestations/sigstore@5.0.0",
"provenance": {
"predicateType": "https://slsa.dev/provenance/v1"
}
}
}The attestations endpoint returns two Sigstore bundles. One is npm's own publish attestation. The other is the SLSA provenance, a DSSE envelope whose base64 payload is an in-toto Statement. Decoded and trimmed, it reads:
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [{
"name": "pkg:npm/sigstore@5.0.0",
"digest": { "sha512": "849a897e81bf7b8a8541a6ae40bd3473a27a16b1cb0462ad905bd6dc96d34881053a12eb4a375233f81677fc9439f628fe0b0445e8535c60b5559597fbd5f80e" }
}],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
"externalParameters": {
"workflow": {
"ref": "refs/heads/main",
"repository": "https://github.com/sigstore/sigstore-js",
"path": ".github/workflows/release.yml"
}
},
"resolvedDependencies": [{
"uri": "git+https://github.com/sigstore/sigstore-js@refs/heads/main",
"digest": { "gitCommit": "7d2900eca1c22b3f87c13987c8d4b7c9a29b733a" }
}]
},
"runDetails": {
"builder": { "id": "https://github.com/actions/runner/github-hosted" },
"metadata": { "invocationId": "https://github.com/sigstore/sigstore-js/actions/runs/26781866618/attempts/1" }
}
}
}The subject digest is the binding. It must equal the SHA-512 of the tarball you download, which is also the hex form of the registry's base64 integrity value:
$ curl -s https://registry.npmjs.org/sigstore/-/sigstore-5.0.0.tgz | sha512sum
849a897e81bf7b8a8541a6ae40bd3473a27a16b1cb0462ad905bd6dc96d34881053a12eb4a375233f81677fc9439f628fe0b0445e8535c60b5559597fbd5f80e -
$ echo 'hJqJfoG/e4qFQaauQL00c6J6FrHLBGKtkFvW3JbTSIEFOhLrSjdSM/gWd/yUOfYo/gsERehTXGC1VZWX+9X4Dg==' | base64 -d | od -An -tx1 | tr -d ' \n'
849a897e81bf7b8a8541a6ae40bd3473a27a16b1cb0462ad905bd6dc96d34881053a12eb4a375233f81677fc9439f628fe0b0445e8535c60b5559597fbd5f80eThe envelope is signed with a short-lived certificate from Sigstore's Fulcio CA. Fulcio encodes the CI identity into X.509 extensions, documented in its OID reference. These are the fields that matter for SLSA levels:
Validity
Not Before: Jun 1 21:02:50 2026 GMT
Not After : Jun 1 21:12:50 2026 GMT
X509v3 Subject Alternative Name: critical
URI:https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main
1.3.6.1.4.1.57264.1.9 (Build Signer URI): https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main
1.3.6.1.4.1.57264.1.11 (Runner Environment): github-hosted
1.3.6.1.4.1.57264.1.13 (Source Repository Digest): 7d2900eca1c22b3f87c13987c8d4b7c9a29b733a
1.3.6.1.4.1.57264.1.18 (Build Config URI): https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/mainThe certificate was valid for exactly 10 minutes, and the signature was recorded in the Rekor transparency log at index 1697019812.
1 · Registry metadata
GET registry.npmjs.org/sigstore/5.0.0
2 · The bytes you install
sha512sum sigstore-5.0.0.tgz
3 · Sigstore bundle (DSSE envelope)
4 · in-toto Statement v1 · slsa.dev/provenance/v1
Figure 1 shows the verification chain for sigstore@5.0.0 as four nested layers. Layer 1 is the registry metadata from GET registry.npmjs.org/sigstore/5.0.0, whose dist.integrity is sha512-hJqJfoG/…9X4Dg== and whose dist.attestations URL is /-/npm/v1/attestations/sigstore@5.0.0. Decoding that integrity value from base64 to hex must give the same digest as layer 2, the SHA-512 of the downloaded tarball, 849a897e…fbd5f80e. That digest must in turn equal the subject of layer 4, the in-toto Statement with predicate type slsa.dev/provenance/v1, which names the build type github-actions-buildtypes/workflow/v1, the workflow release.yml on refs/heads/main in sigstore-js, the commit 7d2900eca1c2…, and the builder github.com/actions/runner/github-hosted. The statement sits inside layer 3, a Sigstore DSSE bundle signed by a Fulcio certificate valid from 21:02:50 to 21:12:50 UTC, whose identity is the release workflow and whose signature is logged in Rekor at index 1697019812. Every step is a digest comparison or a signature check, and none of them inspects the code.
A verifier following the SLSA verification procedure checks the signature against a trusted root, matches the subject digest to the artifact, then compares builder.id, the source repository, buildType, and externalParameters against expectations for the package. The spec says verifiers "SHOULD reject unrecognized fields in externalParameters," because an unexpected parameter is how an attacker injects unofficial behavior.
SLSA Build Level 3 is the first level at which code running inside your build cannot forge your provenance. At L2, the platform signs, but the signing identity may live in the same environment as untrusted build steps. At L3, it must not.
Look at the certificate above again. The Build Signer URI and the Build Config URI are the same file, release.yml. Fulcio's OID reference says the signer "MAY be the same as Build Config URI," and gives a reusable workflow as the example of a separate signer. When they are equal, the workflow that ran checkout, install, build, and test is the same workflow that requested the signing identity.
On GitHub Actions, that identity is an OpenID Connect (OIDC) token. The id-token: write permission is granted per job or per workflow, never per step, and GitHub's OIDC reference shows a plain shell step fetching the token with ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN. Any code that executes in that job can do the same: a dependency's lifecycle script, a poisoned cache entry, or fork code checked out by a careless trigger. With trusted publishing, that same OIDC identity both authenticates the publish and signs the provenance.
The SLSA project's own post-incident analysis of the TanStack attack states it directly: "npm's built-in provenance achieves Build L2." GitHub's artifact attestation docs agree on the platform side: "Artifact attestations by itself provides SLSA v1.0 Build Level 2," and builds that sign inside a reusable workflow can reach Level 3.
Build L2 · npm's built-in provenance
One job holds the build and the signing identity
One job · release.yml · id-token: write
Any code that runs in steps 1 to 4 can reach steps 5 to 7. The certificate's signer and config are the same file: release.yml.
Build L3 · isolated signer
Untrusted steps never touch the signing identity
Job A: build (untrusted steps)
Job B: reusable signer workflow
The signer is a different workflow from the caller, so a verifier can pin it with --signer-workflow.
Figure 2 compares the two layouts. In the Build L2 layout, which is npm's built-in provenance, a single job named release.yml with id-token: write runs seven steps in order: check out code, restore the Actions cache, install dependencies with lifecycle scripts enabled, build and test, request the OIDC token, sign provenance through Fulcio, and run npm publish. Any code that runs in the first four steps can reach the last three, and the certificate lists release.yml as both signer and config. In the Build L3 layout, job A runs the untrusted steps (check out, restore cache, install, build, test) and outputs only the tarball and its SHA-512. Job B, a separate reusable signer workflow, receives the artifact and digest, requests the OIDC token, signs provenance over the digest, and publishes. Because the signer is a different workflow from the caller, a verifier can pin it.
L3 is not free. The release has to be split into a build job and a signer job that the caller cannot edit, with the artifact passed between them by digest. The verifier has to know to check the signer, for example with gh attestation verify --signer-workflow. npm's own check will not do that for you: its trusted publishing docs say that for a reusable workflow, "validation checks the calling workflow's name instead of the workflow that actually contains the publish command." The reference L3 builder for GitHub Actions, slsa-github-generator, is no longer actively maintained, and its last release was v2.1.0 in February 2025.
We think registries should make L3 the default publish path rather than an opt-in for maintainers who read the spec. Until then, npm audit signatures tells you an attestation is valid. It cannot tell you the attestation came from an isolated signer.
SLSA provenance stops tampering that happens after a build, but it did not stop any of the four pipeline compromises below. In each case the malicious code entered the build that the provenance names, so the provenance was true.
| Incident | Date | How attacker code got into the build | What the provenance said | SLSA control aimed at it |
|---|---|---|---|---|
| Ultralytics (PyPI) | Dec 4–5, 2024 | A template injection in a workflow triggered by outside pull requests poisoned the GitHub Actions cache that the release workflow restored | Valid attestations matching the ultralytics/ultralytics Trusted Publisher | Build L3 cache isolation |
| TanStack (npm) | May 11, 2026 | A pull_request_target workflow let fork code poison the pnpm cache, and attacker code extracted the OIDC token from runner memory: 84 malicious versions across 42 packages | Valid provenance from TanStack's release workflow | Build L3 cache isolation and signing secrets out of build steps |
| AsyncAPI (npm) | July 14, 2026 | A malicious commit pushed to the next branch was built by the trusted-publishing workflow | Accurately named commit 3eab3ec9…, which already contained the implant | Source track, Levels 3 and 4 |
| @7nohe/openapi-react-query-codegen (npm) | Aug 28, 2026 | An issue_comment workflow checked out a fork's pull request and published it: 10 malicious versions | Named release.yml on refs/heads/main at the clean commit d42d1733 | None. Workflow logic is outside SLSA's scope |
The verdict from this table is uncomfortable. Build L3 addresses two of the four incidents, the Source track addresses one, and nothing in SLSA addresses the fourth. All four attacks were visible in the published tarball. Socket AI Scanner flagged every compromised TanStack artifact within six minutes of publication, according to our TanStack write-up. It does that by analyzing the package contents, not its metadata.
The Ultralytics compromise is the template. Its publishing workflow produced valid attestations, and PyPI verified them. Those attestations were still useful: they gave investigators a signed timestamp and exact source reference that corroborated the cache poisoning theory. Provenance is strong forensic evidence even when it is weak prevention.
TanStack repeated the pattern at scale. Its postmortem, as summarized in our write-up, attributes the publishes to a chain of a pull_request_target workflow, cache poisoning across the fork-to-base trust boundary, and OIDC token extraction from the runner process. Each link in that chain violates a Build L3 isolation requirement. As Andrew McNamara wrote on the SLSA blog after the attack, "A signed artifact is not necessarily a trustworthy one." GitHub has since added cache-mode to GitHub Actions, which lets a workflow or job restrict cache access, but it does not move signing out of the build job.
The AsyncAPI compromise failed one layer earlier. The provenance correctly pointed to source commit 3eab3ec9304aa26081358330491d3cfeb55cc245 on refs/heads/next, and that commit, authored as Your Name <you@example.com>, contained the implant. The build was faithful. The source was not.
The openapi-react-query-codegen case is the most instructive, because the provenance was accurate and still misleading. The release workflow triggered on issue_comment, gated only on the comment's text, then checked out pull/<n>/head from the fork and ran pnpm publish with id-token: write. For issue_comment events, GitHub sets the triggering ref to the default branch. So the provenance recorded refs/heads/main at the last legitimate commit, while the job built a stranger's fork. In the GitHub Actions build type, the source commit in provenance comes from the run's trigger context. A git checkout that a step performs afterward is invisible to it. Every expectation a careful verifier could set, from repository to workflow path to ref, matched. As our analysis of the compromise put it, "Provenance proves which workflow built an artifact; it does not prove that the workflow only builds trusted source."
All four incidents fit one pattern. Since npm adopted OIDC trusted publishing in 2025, the cheapest way to get malware past provenance checks has been to run it inside a trusted workflow. That is why the 2026 Mini Shai-Hulud waves went after CI workflows, caches, and runner memory. GitHub Actions security is now part of the package's provenance, whether or not the provenance says so.
The SLSA Source track, introduced in v1.2, grades the integrity of the source revision that a build consumed. It addresses threats B and C from the table above. The source control system, not the build platform, issues the attestations.
Source L4 is the SLSA form of two-person integrity, and it targets exactly the AsyncAPI failure: a single compromised identity pushing an implant to a release branch. It costs a second reviewer on every protected change, and it only helps when a consumer's policy demands the source attestation. Build provenance alone does not carry a Source level. A Source level also says nothing about the openapi-react-query-codegen case, where the malicious code never reached the repository at all.
Use provenance for what it proves. Pin expectations per package: the source repository, the workflow path, the ref, and the builder. Treat a change in any of them as a security event, the same way you would treat a new maintainer. A package that had provenance in every previous release and suddenly publishes without it deserves the same scrutiny.
Then verify the rest separately. npm's provenance documentation says it "does not guarantee the package has no malicious code" (npm docs), and GitHub's says "artifact attestations are not a guarantee that an artifact is secure." Both are correct. Provenance narrows the question from "where did these bytes come from" to "what did this pipeline build," and only analysis of the package contents answers the second question.
Adoption is the other constraint. When vlt tested 5,000 high-impact npm packages in February 2025, only 3.72% had provenance attestations, per our coverage of vlt's reproduce tool. PyPI is further along: about a quarter of files uploaded in October 2025 used Trusted Publishing, according to PyPI's numbers, and the official publish action has generated PyPI digital attestations by default since v1.11.0. A policy that requires provenance today blocks most of the npm ecosystem.
The open problem is that provenance answers the question attackers have stopped asking. Nobody in the 2026 npm worms tried to forge a signature. They took over the pipeline that produces valid ones. SLSA already describes the fix, Build L3 isolation that keeps untrusted steps away from the signing identity, but it is not the default anywhere in npm's publishing path. Until it is, a green provenance check on npm means "built by this repository's CI," and the question that matters is what else that CI was willing to run.
Manifest confusion is a mismatch between an npm package's registry manifest and the package.json in its tarball, two copies that tools trust inconsistently.
A software bill of materials (SBOM) is a machine-readable inventory of the components, versions, and dependency relationships inside a piece of software.
Software composition analysis (SCA) inventories the third-party code in an application and checks each component for vulnerabilities, licenses, and malware.