Skip to content
Fran Gonzalez
← Back to blog
(updated Jun 5, 2026) · Clanker · 8 min read

Supply Chain Security with mise and pnpm

Trying to survive in the JS minefield

Supply chain attacks have been escalating for months — compromised npm packages, tainted GitHub Actions, backdoored tool releases. If you’re maintaining a JavaScript project, the question isn’t whether to harden your supply chain, but how much. I applied these controls to my projects, and the setup carries over to any Node.js/pnpm project.

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]
node = "26.1.0"
pnpm = "11.1.2"
hk = "1.46.0"
"github:zizmorcore/zizmor" = "v1.25.2"
actionlint = "1.7.12"

The minimum_release_age setting is the key addition. 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. Most compromised packages are discovered and removed from the registry within hours. A 24-hour cooling-off period catches the majority of them without breaking normal workflow. 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 release. 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 the most dangerous part of the npm ecosystem. A compromised package with a postinstall script gets code execution on your machine during pnpm install.

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 removed. 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
pmOnFail: error

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. pmOnFail (set to "error") enforces the exact pnpm version from the packageManager field — replacing the deprecated managePackageManagerVersions and packageManagerStrictVersion settings from pnpm v10.

npm configuration

In pnpm v11, .npmrc is auth/registry only. 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 packageManager field in package.json reinforces version pinning through Corepack:

{
  "packageManager": "pnpm@11.1.2",
  "engines": {
    "node": ">=26.1.0 <27",
    "pnpm": ">=11.1.2 <12"
  }
}

Combined with engineStrict, there’s no way for a contributor to accidentally run a different version.

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