Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·10 min read

Supply Chain Security with mise and pnpm

Trying to survive in the JS minefield

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

Compromised npm packages, tainted GitHub Actions, and backdoored tool releases all target trust placed in the build path.1 I applied the controls below to my Node.js and pnpm projects. Other repositories can use the same layers after adjusting the release-latency and compatibility tradeoffs to their own threat model.

Note

Fact-checked on 2026-07-16 against pnpm 11.10.0 and the linked mise, npm, and GitHub Actions documentation. Examples that show pnpm 11.8.0 record the version used during the original implementation.

The Problem

Supply chain attacks target the tools and dependencies you trust. A compromised npm package, a backdoored GitHub Action, or a tampered tool binary can give an attacker access to your build output, your secrets, or your users.

The projects had lockfiles, HTTPS registry connections, and some pnpm v10 workspace settings, but nothing systematic. No cooling-off period for tool releases. No checksum verification for tool installs. CI workflows using mutable tags instead of pinned commits. The goal was to close those gaps across the full stack: tool installation, dependency management, CI pipelines, and git hooks.

What Changed

The hardening covers five areas: tool installation, dependency management, npm configuration, CI pipelines, and git hooks. Most of these controls existed in pnpm v10: the v11 migration consolidated them and added new defaults.

Tool pinning with mise

mise manages runtime versions across Node.js, pnpm, and developer tools. Every version is pinned to an exact release in mise.toml:

# mise.toml
[settings]
minimum_release_age = "7d"

[tools]
hk = "1.48.0"
node = "26.1.0"
pnpm = "11.8.0"
python = "3.12"
rust = "1.96.0"
"github:zizmorcore/zizmor" = "v1.25.2"
actionlint = "1.7.12"

The minimum_release_age setting is the key addition2. It refuses to install any tool release published less than seven days ago. If a tool maintainer’s account is compromised and a backdoored version ships, you have a week to find out before your machine touches it.

The mise.lock file pins SHA-256 checksums for every tool binary on every platform. It also records provenance attestations for tools sourced from GitHub, cryptographic proof that the binary came from the expected release, not a tampered artifact.

# mise.lock (excerpt)
[[tools.pnpm]]
version = "11.1.2"
backend = "aqua:pnpm/pnpm"

[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:2f46bcb7ac3c693af72e06e68467b3a9127cbf2a24b778382bc8beaff8463aee"
url = "https://github.com/pnpm/pnpm/releases/download/v11.1.2/pnpm-darwin-arm64.tar.gz"
provenance = "github-attestations"

pnpm workspace security

pnpm’s workspace settings ship a set of supply chain controls that go well beyond what .npmrc alone can do. These are the ones worth enabling.

Blocking fresh packages

The minimumReleaseAge setting (in minutes) rejects any npm dependency published less than N minutes ago. 1440 (one day) is a reasonable default:

# pnpm-workspace.yaml
minimumReleaseAge: 1440

This is the npm equivalent of mise’s minimum_release_age. A 24-hour cooling-off period gives registries, maintainers, researchers, and users an additional day to identify a bad release before automated adoption. Detection time varies, and this window does not imply that most malicious releases are found within a day.3 Packages that genuinely need to be installed fresh get listed under minimumReleaseAgeExclude:

minimumReleaseAgeExclude:
  - nuxt@4.4.7

Trust policy

The trustPolicy setting prevents installing a version of a package that has weaker trust evidence than a previous release4. Set to no-downgrade, pnpm will refuse to install if a package that previously shipped with provenance suddenly stops:

trustPolicy: no-downgrade

Packages that don’t yet support provenance but are known-safe get excluded:

trustPolicyExclude:
  - chokidar@4.0.3
  - semver
  - tinyclip@0.1.13

These exclusions are stopgaps. The goal is to remove them as upstream packages adopt provenance.

Blocking exotic sub-dependencies

The blockExoticSubdeps setting ensures that only your direct dependencies can come from git repos or tarball URLs. Transitive dependencies, the ones you didn’t choose and aren’t reviewing, must resolve from a trusted registry:

blockExoticSubdeps: true

Controlling install scripts

Install scripts (postinstall, preinstall) are a high-risk part of the npm lifecycle because they execute package-controlled code during pnpm install.5

pnpm v11 uses allowBuilds, a map of package names to true or false, to whitelist exactly which packages may run scripts:

# pnpm-workspace.yaml (pnpm v11)
allowBuilds:
  "@parcel/watcher": true
  better-sqlite3: true
  esbuild: true
  sharp: true
  unrs-resolver: true
  vue-demi: true

Every package not listed is blocked by default.

If you’re upgrading from pnpm v10, the old onlyBuiltDependencies (a list of allowed packages) and ignoredBuiltDependencies (a blocklist) have been consolidated into allowBuilds and removed6. The managePackageManagerVersions and packageManagerStrictVersion settings are also gone, replaced by pmOnFail. Running pnpx codemod run pnpm-v10-to-v11 handles the migration.

The strictDepBuilds setting (default: true) makes this enforced: any package with an unreviewed build script causes the install to fail, not just warn.

Additional hardening

A few more settings worth enabling:

strictPeerDependencies: true
strictStorePkgContentCheck: true
preferFrozenLockfile: true
saveExact: true
engineStrict: true

strictPeerDependencies fails the install on missing or invalid peer dependencies. strictStorePkgContentCheck validates package names and versions against store contents, catching registry anomalies. preferFrozenLockfile skips dependency resolution when the lockfile already satisfies package.json, preventing unnecessary lockfile mutations.

saveExact pins exact versions when adding new dependencies (no ^ or ~ prefixes). engineStrict fails installs when a dependency targets an incompatible Node.js or pnpm version.

Warning

The earlier version of this config included pmOnFail: error paired with a Corepack packageManager field in package.json. That setup required coordination between two Renovate managers: the mise manager updates mise.toml; the npm manager updates the packageManager and engines fields. They land in separate PRs. Until both merge, the field and the running pnpm disagree, and pmOnFail: error turns the disagreement into a CI failure. The structural fix is to drop the packageManager field, drop pmOnFail: error, and rely on mise.toml plus mise.lock plus engineStrict (range guard). The two pins collapse into one, and the coordination rule that this setup required disappears. The failure trace and the drift pattern are in this TIL. The architectural pattern is in the self-contained-mise post.

npm configuration

In pnpm v11, .npmrc is auth/registry only7. All other settings belong in pnpm-workspace.yaml. This is a change from pnpm v10, where .npmrc was the primary config file. Here’s where each setting lives:

Setting.npmrc (auth only)pnpm-workspace.yaml
_authToken, _auth, certificates
registry
strict-ssl
save-exact
engine-strict
strict-peer-dependencies
audit, audit-level
verify-store-integrity
fetch-retries, fetch-retry-*

After migrating to pnpm v11, simple pnpm projects can remove .npmrc entirely: all project policy lives in pnpm-workspace.yaml. Projects that don’t use private registries or custom auth tokens have no reason to keep the file:

# pnpm-workspace.yaml (pnpm v11)
saveExact: true
engineStrict: true

The engines field in package.json guards version compatibility through engineStrict:

{
  "engines": {
    "node": ">=26.1.0 <27",
    "pnpm": ">=11.8.0 <=12"
  }
}

Note

The earlier version of this config included a Corepack packageManager field ("packageManager": "pnpm@11.1.2") paired with pmOnFail: error. That setup created a second source of truth that drifted from mise.toml under Renovate’s split managers. The mise-as-sole-enforcer setup drops the packageManager field and pmOnFail: error; engineStrict with range guards stays. See the Corepack drift TIL for the failure trace.

Combined with engineStrict, this rejects contributor environments outside the declared version range. It does not force every contributor onto the exact mise pin unless they invoke the toolchain through mise.

CI hardening

The GitHub Actions workflows got three changes.

SHA-pinned actions. Every uses: reference now points to a full commit hash, not a mutable tag. A compromised tag (e.g., actions/checkout@v6 being replaced with a backdoored v6) can’t affect the workflow:

- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
  with:
    persist-credentials: false

The version comment is for readability. The SHA is what the runner resolves. GitHub’s security hardening guide recommends this as the primary defense against compromised third-party actions.

persist-credentials: false. By default, actions/checkout writes the auth token to the local git config. Setting persist-credentials: false (documented in the checkout action) prevents that token from lingering in the runner environment.

Minimal permissions. The top-level permissions key locks down the GITHUB_TOKEN to read-only. Individual jobs declare only what they need:

permissions: {} # deny-all at top level

jobs:
  ci:
    permissions:
      contents: read

This follows the least-privilege principle: if a step is compromised, it can only do what its job’s permissions allow.

Adding pnpm audit as a dedicated CI step catches known vulnerabilities before they ship:

- name: Audit production dependencies
  run: pnpm audit --prod --audit-level high

The pnpm audit command checks the dependency tree against the npm advisory database. Running it in CI means vulnerable dependencies block the merge.

Git hooks with hk

hk runs linting, formatting, and type checks as pre-commit hooks. The configuration pins hk itself to an exact release via its amends directive:

# hk.pkl
amends "package://github.com/jdx/hk/releases/download/v1.46.0/hk@1.46.0#/Config.pkl"

Hooks route through mise-managed tools, so the same pinned versions run locally and in CI. The pre-commit hook enforces formatting, linting, and type checking before any code reaches the repository.

The layers above block the most common supply chain attack vectors with minimal friction. But static config alone isn’t enough: the next post covers automated dependency updates with supply chain security, and a third covers keeping exclusions honest as your dependency tree evolves.

References

Footnotes

  1. The OpenSSF Malicious Packages Repository and GitHub’s advisory database both track an increasing volume of npm supply chain incidents through 2024-2025.

  2. mise also supports trust_policy for verifying tool provenance, but minimum_release_age is the simpler first step. It requires no cryptographic verification, just patience.

  3. Individual incident reports show detection times ranging from hours to days, but they do not establish an aggregate threshold. The 24-hour value is a project policy that balances an added observation window against patch latency.

  4. Provenance attestations use SLSA (Supply-chain Levels for Software Artifacts) to cryptographically verify that a package was built by the expected publisher from the expected source. A trust downgrade means a package that previously included this proof no longer does.

  5. npm install scripts run arbitrary code with the permissions of the user running the install. In CI, that’s often elevated. The npm documentation acknowledges this risk but leaves mitigation to package maintainers and tooling.

  6. The migration path is documented in the pnpm v11 changelog. Running pnpm codemod run pnpm-v10-to-v11 handles the config migration automatically.

  7. This separation was introduced to reduce the attack surface of .npmrc. Previously, a malicious .npmrc could override package resolution settings. Moving non-auth config to pnpm-workspace.yaml (which is version-controlled and reviewed) closes that vector.

This post was written with AI assistance.