DEV Community

Teruo Kunihiro
Teruo Kunihiro

Posted on

Lessons from the Spring 2026 OSS Incidents: Hardening npm, pnpm, and GitHub Actions Against Supply-Chain Attacks

March 2026 saw a rapid succession of OSS supply-chain incidents.

  • In Trivy, an attacker repointed 76 of the 77 version tags for trivy-action and 7 tags for setup-trivy to a malicious commit, and a tampered v0.69.4 binary was released.
  • In LiteLLM, malicious 1.82.7 and 1.82.8 packages were uploaded to PyPI, and the maintainers later identified 1.83.0 as the clean release.
  • In axios, 1.14.1 and 0.30.4 were briefly published to npm, and the hidden dependency plain-crypto-js used postinstall to distribute a cross-platform RAT (remote access trojan that allows attackers to remotely control infected machines). (Aqua)

A common recommendation for preventing incidents like these is to enable npm’s min-release-age or pnpm’s minimumReleaseAge.
npm’s min-release-age prevents versions newer than a specified number of days from being installed, while pnpm’s minimumReleaseAge applies the same idea in minutes.
Both are highly effective at reducing the chance of immediately picking up a freshly published malicious release. But they only protect you at the moment of dependency resolution. They do not stop automatic install script execution, CI pipelines that reference mutable tags, or long-lived publish tokens lingering in your environment. pnpm itself makes this distinction explicit: compromised packages are often detected relatively quickly, but there is still an unavoidable exposure window between publication and detection. (npm Docs)

One screenshot captured the direction of travel perfectly. In the current stable pnpm release, both blockExoticSubdeps and strictDepBuilds default to false, but in the next docs and the v11 release notes, both move to true. blockExoticSubdeps prevents transitive dependencies from pulling from exotic sources such as git repos or tarball URLs, while strictDepBuilds can fail installation when unreviewed build scripts are present.
pnpm is clearly steering toward a security-first model: away from “install anything” and toward “resolve and execute only what has been explicitly trusted.” (pnpm)

This post breaks the defense surface into four layers:
dependency resolution, install-time execution, CI execution, and the publish path.
min-release-age belongs primarily to the dependency-resolution layer.

Delay and lock dependency resolution

The first thing to stabilize is which versions get resolved. npm’s min-release-age works in days, while pnpm’s minimumReleaseAge works in minutes, allowing you to let newly published versions “cool off” before they are eligible for installation.
In practice, though, you will eventually want exceptions for emergency security fixes or dependencies that you need to update immediately.

pnpm also provides minimumReleaseAgeExclude, which lets you carve out exceptions for specific packages or versions.
Dependabot has cooldown, a grace-period setting that delays version update PRs even after a new dependency version has been published. That grace period applies only to version updates, not to security updates.
So an operating model like “delay routine upgrades, but fast-track urgent security fixes” is perfectly workable in production. (npm Docs)

That said, delaying upgrades is not enough on its own. If the dependency graph resolved at one point in time cannot be reproduced consistently across your team and CI, different environments will drift onto different versions. That is where the lockfile becomes critical.

package-lock.json records the exact dependency graph and versions that were actually resolved. Committing it makes it much easier to reproduce the same dependency set in development and CI. npm ci is designed around the lockfile: it fails if package.json and the lockfile are out of sync, and it never rewrites the lockfile. In CI, that makes npm ci safer than npm install from a reproducibility standpoint, and it also makes unintended dependency changes easier to spot in diffs. (npm Docs)

Lockfiles matter for security, too. In GitHub’s dependency graph, a lockfile gives GitHub a much more accurate picture of the dependencies you actually resolved than a manifest alone. Indirect dependencies inferred only from the manifest may be excluded from vulnerability checks. (GitHub Docs)

There is one more risk in a different category worth calling out: dependency confusion. As a mitigation against public packages colliding with private package names, npm strongly recommends scoped packages. Managing internal packages under a namespace like @your-org/foo is not flashy, but it is effective. (npm Docs)

# .npmrc
min-release-age=3
ignore-scripts=true
Enter fullscreen mode Exit fullscreen mode
# pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
  - '@your-org/*'
Enter fullscreen mode Exit fullscreen mode

Using npm’s min-release-age or pnpm’s minimumReleaseAge helps you avoid immediately consuming newly published versions. npm configures this in days, pnpm in minutes, and pnpm also applies it to transitive dependencies.

But this is only a mechanism for delaying the adoption of new releases. It does not guarantee reproducibility by itself. If you want stable, repeatable installs, the baseline is still to commit the lockfile and enforce strict lockfile-based installs in CI with commands like npm ci or pnpm install --frozen-lockfile. (npm Docs)

Treat install as code execution, not just downloading packages

The axios incident is a perfect example. The problem was not the Axios code itself, but the postinstall hook in the hidden package plain-crypto-js. In other words, npm install is not just artifact retrieval. Through dependency scripts, it is also code execution at install time. (Snyk)

npm has ignore-scripts, and when set to true, it suppresses automatic script execution from package.json during installation. Explicitly invoked scripts such as npm run or npm test still work, but at minimum, you are no longer running every dependency’s preinstall / install / postinstall hook by default. (npm Docs)

pnpm pushes this idea further. In its supply-chain security guidance, pnpm notes that many past compromised packages abused postinstall, and that v10 stopped automatically executing dependency postinstall hooks. The recommended model is to explicitly allow only trusted packages via allowBuilds. In the stable docs, allowBuilds supports per-package allow/deny rules, and with strictDepBuilds enabled, installation can fail the moment an unreviewed build script appears. (pnpm)

On top of that, enabling blockExoticSubdeps prevents transitive dependencies from pulling from exotic sources such as git repositories or tarball URLs. trustPolicy: no-downgrade can reject artifacts whose trust evidence is weaker than what was seen in earlier versions.
All of these are ways to ensure that even if you do pull something bad, it does not automatically spread or execute. (pnpm)

# pnpm-workspace.yaml
minimumReleaseAge: 1440
blockExoticSubdeps: true
strictDepBuilds: true
allowBuilds:
  esbuild: true
trustPolicy: no-downgrade
Enter fullscreen mode Exit fullscreen mode

In short, min-release-age makes it less likely that you will ingest a freshly compromised release, while ignore-scripts and strictDepBuilds are about preventing it from executing automatically even if it does get in. (npm Docs)

Run GitHub Actions with immutable refs and least privilege

In GitHub Actions, the first rule is to pin workflow code to immutable references. Tag references such as @v1 or @v1.2.3 are convenient, but tags can be retargeted after the fact. GitHub explicitly states that the only way to reference an Action immutably is to pin it to a full-length commit SHA. So instead of uses: owner/action@v1, the safer baseline is uses: owner/action@<commit SHA>. If your workflow depends on a moving reference like a tag, the code that runs later can change even when the workflow file itself does not. (GitHub Docs)

The next step is to minimize runtime privileges. Keep GITHUB_TOKEN permissions to the bare minimum, with defaults as narrow as contents: read, and grant additional permissions only to the specific jobs that need them. Protect workflow files themselves with CODEOWNERS, so changes to .github/workflows require review. And for jobs that need cloud access, use OIDC instead of storing long-lived secrets in GitHub. Importantly, permissions: id-token: write is only for minting an OIDC token to authenticate to an external service. It does not expand the workflow’s GitHub-side privileges. (GitHub Docs)

From there, the next defensive layer is to gate dependency changes at the PR boundary. GitHub’s dependency review action checks dependencies added or updated in a pull request and can block merges when known vulnerabilities are introduced. In the review UI, you can inspect newly added or updated dependencies alongside release dates and vulnerability data. For example, the following workflow fails when the PR includes dependency changes with vulnerabilities rated high severity or above. (GitHub Docs)

name: dependency-review

on:
  pull_request:

permissions: {}

jobs:
  review:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<FULL_LENGTH_SHA>
      - uses: actions/dependency-review-action@<FULL_LENGTH_SHA>
        with:
          fail-on-severity: high
Enter fullscreen mode Exit fullscreen mode

There is an important nuance here. The dependency review action is primarily a mechanism for checking the safety of dependency changes introduced via PRs. GitHub also recognizes uses: references in .github/workflows/ as dependencies in the dependency graph, but Dependabot alerts for Actions are only generated automatically for semver-based references. SHA-pinned Actions do not receive those alerts. In practice, that means external Actions should be pinned by SHA for safety, and then reviewed on a schedule as part of deliberate update work. The operating model becomes: stay safe by default with immutable references, and review upgrades intentionally when you choose to move them. (GitHub Docs)

Protect the publish path itself

If you publish npm packages yourself, the publish path can become the source of upstream compromise. npm’s trusted publishing uses OIDC so you do not need to keep long-lived npm tokens in CI. After you configure a trusted publisher, npm strongly recommends restricting legacy token-based publishing and enabling “Require two-factor authentication and disallow tokens”. The docs even walk through revoking old automation tokens after the migration. (npm Docs)

When trusted publishing is used from GitHub Actions or GitLab CI/CD, npm also generates provenance attestations automatically. npm provenance makes it publicly verifiable where a package was built and who published it. In other words, if you publish from GitHub Actions with a trusted publisher configured, you usually do not need to explicitly add npm publish --provenance; provenance is attached automatically. (npm Docs)

name: publish

on:
  release:
    types: [published]

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<FULL_LENGTH_SHA>
      - uses: actions/setup-node@<FULL_LENGTH_SHA>
        with:
          node-version: "24"
          registry-url: "https://registry.npmjs.org"
      - run: npm ci
      - run: npm publish
Enter fullscreen mode Exit fullscreen mode

It is worth separating signatures from provenance here. npm’s ECDSA registry signatures are designed to verify that the distributed tarball was not tampered with in transit. For example, they can detect whether package contents were altered somewhere along the way by a mirror or proxy.

Provenance, on the other hand, captures where a package came from, how it was built, and from which source code it was published. So while signatures answer “Was the package that arrived here modified?”, provenance answers “Where did this package come from, and how was it produced?”

npm audit signatures can verify both registry signatures and provenance attestations. But it is best thought of as a complementary integrity-and-origin check, not the primary mechanism for day-to-day vulnerability detection. (npm Docs)

pnpm takes a slightly different posture. In addition to “verify later” mechanisms like npm’s signatures and provenance, pnpm can proactively block untrusted dependencies at install time with settings like blockExoticSubdeps and strictDepBuilds. In that sense, npm focuses more on verification, while pnpm also leans into prevention through install-time policy.

Cross-cutting controls: detect with SCA, block with package-manager policy

This is where SCA becomes important. SCA (Software Composition Analysis) is the practice of enumerating the libraries your project depends on and continuously checking them for known vulnerabilities and license issues. It is the foundation for understanding what is actually in your stack and whether any of it is already known to be risky.

In GitHub, that role is largely filled by the dependency graph. The dependency graph ingests dependencies from manifests and lockfiles, and dependencies that land in the graph can receive Dependabot alerts and security updates. GitHub also explicitly recommends lockfiles for building a more trustworthy graph. The flip side is that transitive dependencies resolved only at build time, or indirect dependencies inferred only from the manifest, can still be missed. (GitHub Docs)

That is what automatic dependency submission and the dependency submission API are for. They let you send not just lockfile-declared dependencies, but also the dependencies actually resolved by a real build, into the dependency graph. GitHub provides built-in workflows for this, and external CI/CD systems or custom build pipelines can also push dependency snapshots through the API. In other words, you can reflect not only statically visible dependencies, but also the dependencies that were actually resolved at runtime. (GitHub Docs)

External tools are easier to reason about when you split them by role. Snyk Open Source is a classic SCA tool for open-source dependency vulnerabilities and license issues. OSV-Scanner supports major JavaScript lockfiles including package-lock.json, pnpm-lock.yaml, yarn.lock, and bun.lock. Trivy can emit GitHub dependency snapshots with --format github, which makes it useful as a bridge for feeding dependencies observed from images or artifacts back into GitHub’s dependency graph. (Snyk User Docs)

Many of these tools are strongest at known vulnerabilities, advisories, and license metadata. Socket is addressing a different problem: through static analysis, it looks for suspicious behavior such as install scripts, network requests, environment variable access, telemetry, and obfuscated code, including cases that have not yet become formal advisories.

The key point is that SCA alone is not enough. It can catch known vulnerabilities, but there is always a lag for freshly published malware or suspicious packages that have not yet been assigned an advisory. As pnpm points out, there is an unavoidable gap between the publication of malware and its detection. In practice, that is why you should not rely on detection alone. You also need preventive controls at the package-manager level—such as minimumReleaseAge, ignore-scripts, blockExoticSubdeps, and strictDepBuilds—to make risky dependencies both harder to ingest and harder to execute in the first place. (pnpm)

The minimum baseline to put in place today

  • Add min-release-age=3 and ignore-scripts=true to .npmrc. npm provides the former as a day-based maturity window and the latter as a way to suppress automatic script execution. (npm Docs)
  • Always commit the lockfile, and use npm ci in CI. npm ci fails on lockfile mismatch and never rewrites the lockfile. (npm Docs)
  • Scope private packages. It is a basic but effective mitigation against dependency confusion. (npm Docs)
  • If you use pnpm, enable minimumReleaseAge, blockExoticSubdeps, strictDepBuilds, and allowBuilds, and consider going as far as trustPolicy: no-downgrade if appropriate. (pnpm)
  • In GitHub Actions, combine full-length commit SHA pinning, least-privilege GITHUB_TOKEN settings, and CODEOWNERS review requirements for workflow changes. (GitHub Docs)
  • Move cloud authentication to OIDC, and grant id-token: write only to the jobs that need it. (GitHub Docs)
  • Add the dependency review action to PRs so dependency diffs are reviewed before merge. Use GitHub dependency graph / Dependabot as the baseline monitoring layer for dependency visibility. (GitHub Docs)
  • If you publish packages, migrate to trusted publishing, disable legacy tokens, and revoke the ones you no longer need. (npm Docs)

Closing thoughts

Delay resolution. Prevent install-time auto-execution. Pin references and permissions in CI. Eliminate long-lived credentials from the publish path, attach provenance, and verify what you ship. Then use SCA to monitor dependency drift and known risk.

Only when these controls are combined can you say you have actually started defending against supply-chain attacks. (npm Docs)

Top comments (0)