# Pin Node Dependencies

> Supply-chain audit for Node.js (js/ts) repos: verifies every package.json dependency is pinned to an exact version (no ^ ~ >= * latest or mutable git refs), checks the lockfile is committed, reports drift with evidence, optionally rewrites to exact pins, and wires up the Miragon/pin-npm-dependencies CI guardrail + save-exact.

- Skill: `emaarco/pin-node-dependencies` (Agent Skill)
- Install (CLI): `npx skillmds@latest add emaarco/pin-node-dependencies`
- Raw SKILL.md: https://api.skillmd.com/api/skills/emaarco/pin-node-dependencies/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: emaarco (https://skillmd.com/u/emaarco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/emaarco/pin-node-dependencies

---


# Skill: pin-node-dependencies

Audits a Node.js / JS / TS repository and verifies that **every dependency in every `package.json` is pinned to an exact version**. Version ranges (`^1.2.3`, `~1.2.3`, `>=1`, `*`, `1.x`), floating dist-tags (`latest`, `next`), and mutable git refs (`#main`) mean `npm install` can silently pull a *different* build than the one that was reviewed and tested — the delivery vector behind worm-style npm supply-chain attacks. Exact pinning + a committed lockfile makes installs reproducible and shrinks the window for a malicious republish to slip in.

Run this when asked to "check if dependencies are pinned", harden the npm supply chain, or as the Node slice of a release/supply-chain audit (see the sibling skill **`release-audit`**). Pairs with **`pin-github-actions`** for the CI side.

## Pinning rule

A spec is **pinned** only when it resolves to one immutable artifact:

| Spec | Verdict |
|---|---|
| `1.2.3`, `1.2.3-beta.1` (exact semver) | ✅ pinned |
| `npm:pkg@1.2.3` (exact alias) | ✅ pinned |
| `git+https://…#<40-hex-sha>` (commit-pinned git dep) | ✅ pinned |
| `https://…/pkg-1.2.3.tgz` (exact tarball) | ✅ pinned |
| `workspace:*` / `workspace:^` (workspace protocol, monorepo) | ✅ N/A — resolved locally at publish |
| `file:`, `link:`, `portal:` (local) | ✅ N/A |
| `^1.2.3`, `~1.2.3`, `>=1`, `<2`, `1.2.x`, `*`, `1 \|\| 2` | ❌ range |
| `latest`, `next`, any dist-tag | ❌ floating |
| `git+https://…#main` / `#master` / branch/tag | ❌ mutable ref |

Applies to `dependencies`, `devDependencies`, `peerDependencies`, and `optionalDependencies`. (`peerDependencies` are intentionally ranged in *published libraries* — flag them as `Low`/optional there, not as a hard failure.) This mirrors the repo's existing `package-json-style` rule.

## Phase 1 — Detect

Find every manifest (skip `node_modules`) and scan the four dependency blocks:

```bash
find . -name package.json -not -path '*/node_modules/*' -not -path '*/.git/*'

# Per file, list specs that are NOT exact (null-safe over missing blocks; keeps the block name):
jq -r '
  to_entries[]
  | select(.key | IN("dependencies","devDependencies","peerDependencies","optionalDependencies"))
  | .key as $block | .value | to_entries[]
  | select(.value | test("^([0-9]+\\.[0-9]+\\.[0-9]+([-+][0-9A-Za-z.-]+)?$|workspace:|file:|link:|portal:|npm:.*@[0-9]+\\.[0-9]+\\.[0-9]+([-+][0-9A-Za-z.-]+)?$|git\\+.*#[0-9a-f]{40}$|https?://.*\\.(tgz|tar\\.gz)$)") | not)
  | "\($block)  \(.key): \(.value)"
' path/to/package.json
```

The semver, `npm:` alias, git, and tarball alternatives are end-anchored (`$`) on purpose — without the anchor, prefix matches classify hyphen ranges (`1.2.3 - 2.0.0`), OR ranges (`1.2.3 || 2.x`), and floating aliases (`npm:pkg@1.x`) as pinned. Keep the anchors if you adapt the filter.

Detect the package manager + lockfile so transitive deps are covered too:

```bash
ls package-lock.json npm-shrinkwrap.json pnpm-lock.yaml yarn.lock bun.lock bun.lockb 2>/dev/null
git ls-files | grep -E '(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lock|bun\.lockb)$'
```

## Phase 2 — Classify & report

Report a table per manifest, **unpinned-first**, with severity:

- `High` — runtime `dependencies` with a range/dist-tag, or any mutable git ref → ships unreviewed code to production.
- `Medium` — `devDependencies`/`optionalDependencies` with a range.
- `Low` — `peerDependencies` ranges in a published library (often intentional).

| package.json | Dependency | Spec | Block | Verdict | Severity |
|---|---|---|---|---|---|

Then a **lockfile finding**: pinning `package.json` alone does **not** pin transitive dependencies — flag `High` if no lockfile is committed (`git ls-files` shows none), since direct pins still float underneath. Recommend committing the lockfile and using `npm ci` (not `npm install`) in CI.

End with: `N manifests · M deps · K unpinned · lockfile: present/missing`.

## Phase 3 — Fix (only when the user asks)

Convert each ranged spec to the **currently-resolved exact version** — prefer the version already in the lockfile (what's actually installed) over the registry's newest, so the fix doesn't silently bump:

```bash
# Resolved version from a committed lockfile (npm v3 lockfile):
jq -r '.packages["node_modules/<pkg>"].version' package-lock.json
# Fallback (introduces newest — confirm with the user first):
npm view <pkg> version
```

Rewrite the spec in `package.json`, then make exact-pinning the local default so new installs stay pinned:

```bash
# .npmrc — applies to npm install / npm i --save
save-exact=true
```

(pnpm: `save-exact=true` in `.npmrc`; yarn classic: `--exact` / `save-exact true` in `.yarnrc`.) Refresh the lockfile (`npm install`) and re-run Phase 1 to confirm zero ranges remain.

## Phase 4 — CI guardrail & automation

Add a CI check so ranges can never reappear, and keep pins fresh.

**Enforce exact pins in CI** with [`Miragon/pin-npm-dependencies`](https://github.com/Miragon/pin-npm-dependencies) — a GitHub Action that fails the build if any `package.json` uses a range, wildcard, dist-tag, or mutable git ref. Pin the action itself to a SHA (see `pin-github-actions`). Ship it as a **dedicated workflow** with a fully-scoped `on:` block already filled in — never leave the trigger blank for the user to copy from a neighbouring workflow:

```yaml
# .github/workflows/pin-check.yml — a standalone workflow, NOT appended to an existing ci.yml
# (the on:/concurrency below would otherwise govern every job in that file).
on:
  push:
    branches: [ main ]          # post-merge backstop on the default branch only
  pull_request:                 # PR feedback — scoped to manifest/lockfile changes
    paths:
      - '**/package.json'
      - '**/package-lock.json'  # match the repo's lockfile: pnpm-lock.yaml / yarn.lock / bun.lock

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }}

jobs:
  pin-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: Miragon/pin-npm-dependencies@58fe24377aef66748a1444ff69ab05e3f938a798 # v1.2.1
```

The SHAs/versions in the snippet are placeholders that drift — resolve each action's current release SHA at apply time (`gh api repos/<owner>/<repo>/commits/<latest-tag> --jq .sha`, as `pin-github-actions` Phase 3 does) instead of copying them verbatim.

> **Never leave the `on:` block for the user to fill in, and never combine `push: branches: ['**']` with `pull_request`.** A blank trigger invites copying it from the nearest workflow — often a build job with `push: branches: ['**']` **and** `pull_request`, which fires the check *twice* on every PR push (once as `push` → `refs/heads/<branch>`, once as `pull_request` → `refs/pull/<n>/merge`). Because the two events resolve to different refs, a `${{ github.ref }}` concurrency group can't collapse them either. Scope `push` to the default branch (post-merge backstop) and let `pull_request` carry PR feedback, as above. See the sibling skill **`optimize-github-actions`** for the general duplicate-run audit.

Useful inputs: `root-path` (scan a monorepo subdir), `files` (explicit newline-separated list), `check-peer-dependencies` (default `false`), `check-optional-dependencies` (default `true`). Recursive scan from repo root is the default.

**Keep exact pins current** — exact pins go stale, so let a bot bump them with a cooldown that blunts compromised-release windows:

```yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: "/"
    schedule: { interval: weekly }
    cooldown: { default-days: 5 }   # don't adopt a release in its first days
```

(Renovate equivalent: `rangeStrategy: "pin"` + `minimumReleaseAge`.)

## Sources

- Miragon/pin-npm-dependencies: https://github.com/Miragon/pin-npm-dependencies
- npm `save-exact` config: https://docs.npmjs.com/cli/using-npm/config#save-exact
- npm `ci` (reproducible installs from lockfile): https://docs.npmjs.com/cli/commands/npm-ci
- Dependabot cooldown: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#cooldown

