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
- mise — tool version manager, used for pinning Node.js, pnpm, and dev tools
- mise configuration — mise.toml settings reference
- mise lockfile — checksum and provenance lockfile documentation
- mise
minimum_release_age— cooling-off period for tool releases - pnpm settings — pnpm-workspace.yaml configuration reference
- pnpm
minimumReleaseAge— cooling-off period for npm packages - pnpm
trustPolicy— trust downgrade prevention - pnpm
blockExoticSubdeps— blocking transitive exotic sources - pnpm
allowBuilds— install script allowlist (replaces v10’sonlyBuiltDependencies) - pnpm
strictDepBuilds— enforce build script review - pnpm
verifyStoreIntegrity— content-addressable store verification - pnpm
--frozen-lockfile— lockfile immutability during install - pnpm
audit— vulnerability checking against npm advisory database - pnpm
.npmrc— auth and registry settings only (v11) - pnpm
saveExact— exact version pinning for new dependencies - pnpm
engineStrict— engine compatibility enforcement - Corepack
packageManager— package manager version enforcement - GitHub Actions security hardening — SHA pinning, third-party action safety
- GitHub Actions
permissions— least-privilege token scoping - actions/checkout —
persist-credentialsdocumentation - hk — git hooks manager
- hk configuration — hk.pkl configuration reference
- pnpm v10 to v11 migration —
allowBuildsmigration fromonlyBuiltDependencies - pnpm
strictPeerDependencies— enforce peer dependency validity - pnpm
strictStorePkgContentCheck— validate package contents against store - pnpm
preferFrozenLockfile— skip resolution when lockfile is satisfied - pnpm
pmOnFail— package manager version enforcement (replaces deprecated settings)