javascript-supply-chain-hardening Skill
SKILL.md
JavaScript Supply-Chain Hardening
Use this when you need to reduce supply-chain risk in a JavaScript or TypeScript repository, especially after npm registry/package compromise news, before shipping a public package, or when cleaning up inconsistent package-manager usage.
This is not a silver bullet. It is a pragmatic first line of defence: make installs deterministic, slow down adoption of brand-new package releases, remove package-manager drift, and ensure CI proves the repo still builds.
What Done Looks Like
A repo is only hardened when the policy is committed and verified:
- One package manager is used consistently, preferably
pnpmfor new hardening work. package.jsondeclares apackageManagerversion.- The repo has a committed lockfile for that package manager.
- New package releases are delayed before install where supported, usually with a 3-day age gate.
- CI installs with a frozen lockfile.
- CI runs the repo's real quality gates:
validate,check, or the available lint/typecheck/test/build scripts. - Package build scripts are denied by default or explicitly approved with a reviewed trust policy.
- Node, pnpm, Docker base images, and CI actions are pinned intentionally instead of floating silently.
- Pin update automation exists, or there is an explicit manual review cadence.
- Published packages use provenance/two-factor release controls where the registry supports them.
- Old lockfiles and stale CI commands are removed unless intentionally kept.
Quick Audit
Start by inspecting the repo before changing anything:
git status --short
node --version
corepack --version || true
ls
Then check package-manager state:
node -e "const p=require('./package.json'); console.log({packageManager:p.packageManager, scripts:Object.keys(p.scripts||{}).sort()})"
for f in package-lock.json yarn.lock pnpm-lock.yaml bun.lock bun.lockb; do [ -e "$f" ] && echo "$f"; done
find .github/workflows -maxdepth 2 -type f 2>/dev/null || true
find . -maxdepth 3 \( -iname 'Dockerfile*' -o -name 'docker-compose*.yml' -o -name 'compose*.yml' \) 2>/dev/null || true
Classify the repo:
- App, library, CLI, monorepo, or documentation site.
- Private/internal or public/published.
- Current package manager: npm, yarn, pnpm, bun, or mixed.
- Existing CI provider and install commands.
- Dockerfiles or deployment workflows that install dependencies independently.
- Existing quality scripts in
package.json. - Packages that need install-time build scripts, such as native modules, bundlers, ORMs, or browser automation packages.
Do not blindly migrate a repo with unclear constraints. If a tool choice is intentional, preserve it and harden around it.
pnpm Hardening Checklist
For a typical repo, pnpm is a good default because it supports deterministic installs, strict dependency layout, and delayed adoption of new releases.
-
Enable Corepack if needed:
corepack enable || true -
Add or update
packageManagerinpackage.json:{ "packageManager": "pnpm@10.23.0" }Use the current stable pnpm version for the project. Keep CI and docs on the same major version.
-
Add
.npmrcwith a release-age gate:minimum-release-age=43204320minutes is 3 days. This slows down adoption of newly published dependency versions, which gives the ecosystem time to detect obvious malicious releases. -
Add an install-script trust policy. In pnpm 10, approve only the packages whose install/build scripts are expected and reviewed:
pnpm approve-buildsThis writes the approved package list into project config, commonly
onlyBuiltDependenciesand/or ignored build-script entries. Review the generated config like source code. Keep the list short; examples that may legitimately need approval includeesbuild,sharp,@prisma/client,playwright, or other native/tooling packages actually used by the repo.For newer pnpm versions, use the current equivalent trust-policy setting such as
allowBuilds. Do not blindly copy an old config shape across pnpm majors; check the installed pnpm docs/help and keep the package-manager version pinned. -
Install and verify the lockfile:
pnpm install pnpm install --frozen-lockfile -
Remove obsolete lockfiles only after pnpm install succeeds:
rm -f package-lock.json yarn.lock bun.lock bun.lockb -
Update scripts, docs, Dockerfiles, and CI that still call
npm,npx,yarn, orbununless the mixed setup is deliberate. -
Run the repo's real gates. Inspect scripts first, then run only the scripts that exist:
node -e "const p=require('./package.json'); console.log(Object.keys(p.scripts||{}).sort().join('\n'))"Prefer one aggregate gate if present:
pnpm run validate # or pnpm run checkIf there is no aggregate script, run the available individual gates, for example:
pnpm run lint pnpm run typecheck pnpm test pnpm run build
GitHub Actions Pattern
A minimal Node CI job should install pnpm before enabling pnpm cache in actions/setup-node:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.23.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run validate
If the repo has no aggregate validate script, run the scripts that actually exist:
- run: pnpm run lint
- run: pnpm run typecheck
- run: pnpm test
- run: pnpm run build
For apps that need environment variables at build time, use dummy values with the correct shape in CI. Never put production secrets in workflow files.
Pinning Policy for CI, Docker, and Workflows
Pin the toolchain wherever dependencies are installed. A repo is not hardened if local installs use pnpm but Docker or CI still floats versions.
Check for these files and update them together:
find .github/workflows -maxdepth 2 -type f 2>/dev/null || true
find . -maxdepth 3 \( -iname 'Dockerfile*' -o -name 'docker-compose*.yml' -o -name 'compose*.yml' \) 2>/dev/null || true
Good defaults:
- Pin
packageManagerinpackage.json, e.g.pnpm@10.23.0. - Pin
pnpm/action-setupto the same pnpm major/minor used locally. - Pin Node in CI and deployment images, e.g.
node-version: 22orFROM node:22.11.0-slimrather thannode:latest. - For higher-security repos, pin GitHub Actions and Docker base images by digest/SHA, then use Dependabot or Renovate to keep those pins updated.
- Make Docker builds use the same lockfile discipline:
FROM node:22.11.0-slim
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
Avoid floating tags like node:latest, unpinned package-manager installs like npm i -g pnpm, and CI snippets that silently upgrade the package manager.
Pinning creates maintenance work. Add automation or write down the manual cadence:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
For stricter repos, prefer digest/SHA pins plus Dependabot/Renovate updates over mutable tags that never get reviewed.
Docker Build Hygiene
If the repo ships a container, also check the Docker context and install behaviour:
- Add a
.dockerignoreso secrets, local caches,.git, andnode_modulesare not copied into the build context. - Copy dependency manifests before source files so Docker layer caching does not hide install drift.
- Use
pnpm install --frozen-lockfileorpnpm fetch+ offline install patterns; do not use plainpnpm installin production Docker builds. - Do not run package-manager installs from remote shell scripts inside Dockerfiles.
- If using multi-stage builds, ensure every stage that installs dependencies uses the same pinned package-manager policy.
A minimal .dockerignore starting point:
.git
node_modules
.pnpm-store
.env
.env.*
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
.DS_Store
Lockfile Review
A lockfile change is code. Review it instead of treating it as generated noise.
Check for:
- Unexpected new direct dependencies.
- Large dependency-tree changes from a tiny package.json edit.
- New install scripts in transitive packages.
- Git, tarball, file, or non-registry dependency sources.
- Brand-new package versions when no release-age gate was active.
- New or changed approved-build/trust-policy entries.
- Package name confusion: typosquats, abandoned packages, or surprising maintainers.
Useful commands:
git diff -- package.json pnpm-lock.yaml .npmrc pnpm-workspace.yaml .github/workflows Dockerfile 'Dockerfile*'
pnpm audit --audit-level moderate || true
pnpm outdated || true
Treat audit output as triage input, not gospel. The aim is to understand risk, not blindly churn dependencies.
Existing npm/yarn/bun Projects
Prefer improving the repo in-place over doing a performative migration.
- If npm is kept, use
npm ciin CI and commitpackage-lock.json. - If yarn is kept, use immutable installs and a committed lockfile.
- If bun is kept, make sure CI uses the same bun version and lockfile strategy.
- If migrating, remove old lockfiles and update all install docs/scripts in the same change.
Mixed package managers are a smell. Keep them only when there is a documented reason.
Published Packages and CLIs
For public packages, hardening local development is useful, but it does not automatically protect downstream users.
Before release:
-
Confirm package contents with the package manager's dry-run/pack command:
pnpm pack --dry-run -
Verify CI is green from a clean install.
-
Check that generated files, bins, exports, and README instructions still match reality.
-
Use npm provenance for CI-based publishing where supported:
pnpm publish --provenance -
Require 2FA for registry accounts and avoid long-lived publish tokens where trusted publishing is available.
-
Publish only after package-manager and lockfile changes are understood.
A CI-only hardening change does not always require an immediate release. If package contents, runtime dependencies, or user-facing install instructions changed, batch it into the next patch release.
Common Pitfalls
-
Thinking an age gate fixes the existing lockfile. It does not. It only affects future resolution/install behaviour. Still review suspicious lockfile changes.
-
Using the wrong config key. In
.npmrc, use pnpm's kebab-case config:minimum-release-age=4320, not camelCase. -
Installing pnpm too late in GitHub Actions.
actions/setup-nodewithcache: pnpmexpects pnpm to exist. Runpnpm/action-setupfirst. -
Leaving stale CI commands behind. A migrated repo should not still run
npm ci,yarn install, orbun installunless that is intentional. -
Approving every install script. Trust policy is only useful if the allowlist is small and reviewed. If everything is approved, you have mostly recreated the default risk.
-
Floating Docker and CI toolchains.
node:latest, unpinned pnpm installs, and mutable action references can bypass the repo policy. Pin versions; use digest/SHA pins for stricter environments. -
Pinning without updates. Frozen versions reduce surprise but can rot. Pair pins with Dependabot/Renovate or an explicit review cadence.
-
Copying secrets into Docker images. A missing
.dockerignorecan leak.env, local caches, or repo metadata into image layers/build contexts. -
Leaking secrets into CI. Use dummy build-time values with the right shape. Do not paste real tokens, API keys, or database URLs into workflow files.
-
Breaking framework builds by blocking dependency build scripts. Some frameworks and ORMs need explicit generation steps. For example, Prisma apps often need
prisma generatebeforenext build. -
Treating formatting as security. If a legacy repo fails only on old formatting noise, keep install/build security green first and plan formatting cleanup separately.
Verification Checklist
-
package.jsondeclares the intended package manager. - Exactly one intended lockfile is committed.
-
.npmrcor equivalent install policy is committed where supported. - CI uses frozen/immutable installs.
- CI runs real repo gates.
- Install-script trust policy is present and reviewed where pnpm supports it.
- Dockerfiles and deployment workflows use the same package-manager and lockfile policy.
- Node, pnpm, CI actions, and Docker base images are pinned intentionally.
- Pin update automation or a manual review cadence exists.
-
.dockerignoreprevents secrets/caches from entering container build contexts where Docker is used. - Published packages use provenance/2FA/trusted publishing where supported.
- Docs and scripts no longer reference obsolete package-manager commands.
- Lockfile changes have been reviewed.
- Local working tree is clean after the hardening change.