Detecting Lockfile Tampering in Pull Requests

Permalink to "Detecting Lockfile Tampering in Pull Requests"

Part of Lockfile Mapping & Analysis, this page builds a pull request check that reads package-lock.json as a security-relevant artifact rather than generated noise, prints a reviewable table of what changed, and blocks the merge on the handful of diffs that no legitimate dependency update produces.

Quick Reference

Permalink to "Quick Reference"
Signal Evidence in the diff Severity Gate
Foreign resolved host resolved host is not an approved registry High Fail the job
Silent integrity edit integrity changed, version identical High Fail the job
Version downgrade head version sorts below base version High Fail the job
Private scope going public @scope/pkg resolves to registry.npmjs.org High Fail the job
Missing integrity entry has resolved but no integrity High Fail the job
Unexplained transitive add new entry, no package.json change Medium Require a label or owner approval
Non-reproducible lockfile git diff --exit-code dirty after npm ci High Fail the job

The mental model

Permalink to "The mental model"

A lockfile is the only file in the repository that a reviewer is culturally trained to skip. It is thousands of lines long, it is machine-generated, and the diff for a routine dependency bump looks exactly like the diff for a malicious one until you read the fields. That asymmetry is the whole attack: an edit to a single resolved URL or a single integrity token, buried in six hundred lines of legitimate churn, changes which bytes every future npm ci installs on every developer machine and every build agent.

The defence is not “read the lockfile more carefully”. It is to enumerate the small set of changes that a well-behaved package manager never produces, and to make a machine assert their absence on every pull request. There are only five of them worth alerting on, and each one maps to a single field comparison between the base and head copies of the file.

Fields of a lockfile entry and their tampering signature The four security-relevant fields of a package-lock.json entry — version, resolved, integrity and dev — each annotated with the diff pattern that indicates tampering, above a summary noting that all four are visible in the unified diff. "version" 4.17.21 "resolved" registry.npmjs.org "integrity" sha512-base64 "dev" true / false downgraded to a vulnerable release host swapped to a registry you do not own changed while the version stayed the same flipped so a build tool now ships Each field carries its own tampering signature, and all four are visible in the unified diff without ever unpacking a tarball.

Three of the five signals are absolute: they are wrong regardless of intent. A resolved URL pointing at a host other than your registry means the tree was installed against something you did not authorise, whether that is an attacker’s mirror or a developer’s forgotten .npmrc override. An integrity token that changed while the version string did not is a contradiction, because a published registry version is immutable — the same coordinates must always produce the same tarball hash. And a scoped package that used to resolve to your internal registry but now resolves to registry.npmjs.org is textbook dependency confusion: someone published a name you thought was private, and the resolver preferred it.

The other two are contextual. A version that sorts lower than the base branch’s is usually a rollback, sometimes a mistake, and occasionally an attempt to reintroduce a vulnerability that a previous bump had closed — a reviewer needs to say which. A new transitive package with no accompanying package.json change is normally the by-product of a dedupe or a bot’s lockfile maintenance, but it is also exactly what a hand-inserted dependency looks like. Both belong in the report; neither should hard-fail without an escape hatch, or the job becomes something people learn to override. Reading these fields well depends on knowing the schema, which is covered in depth by Parsing package-lock.json for Dependency Audits.

Classifying a changed lockfile entry A decision tree that takes one changed lockfile entry, asks whether the version is unchanged, then whether the integrity changed or the version was lowered, and routes the entry to a blocking failure, a manual review, or an allowed change that must be accompanied by a manifest edit. lockfile diff entry version unchanged? yes no integrity changed? version lower? yes no yes no BLOCK integrity changed, version did not REVIEW host moved, or a new entry appeared BLOCK version downgrade needs sign-off ALLOW if package.json changed too

Canonical example: diff the base and head lockfiles

Permalink to "Canonical example: diff the base and head lockfiles"

The script below is the whole check. It loads package-lock.json from the base SHA with git show, indexes both sides by their packages map key, classifies every difference, and prints a table a reviewer can read in fifteen seconds. It exits non-zero when any high-severity finding is present, and prints Markdown so the output renders as a table when piped into the GitHub Actions step summary.

// scripts/lockfile-diff.js — usage: node scripts/lockfile-diff.js <base-sha>
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

const LOCKFILE = 'package-lock.json';
const BASE_REF = process.argv[2] ?? 'origin/main';
const ALLOWED_HOSTS = new Set(['registry.npmjs.org', 'npm.internal.example.com']);
const PRIVATE_SCOPES = ['@acme'];
const PRIVATE_HOST = 'npm.internal.example.com';

function indexLock(lock) {
  const out = new Map();
  for (const [key, meta] of Object.entries(lock.packages ?? {})) {
    if (key === '' || !key.includes('node_modules/')) continue;
    const name = key.slice(key.lastIndexOf('node_modules/') + 'node_modules/'.length);
    out.set(key, {
      name,
      version: meta.version ?? null,
      resolved: meta.resolved ?? null,
      integrity: meta.integrity ?? null,
    });
  }
  return out;
}

function loadFromRef(ref) {
  try {
    const raw = execFileSync('git', ['show', `${ref}:${LOCKFILE}`], { encoding: 'utf8' });
    return indexLock(JSON.parse(raw));
  } catch {
    return new Map(); // lockfile did not exist on the base branch
  }
}

function hostOf(url) {
  if (!url) return '(none)';
  try {
    return new URL(url).host;
  } catch {
    return '(unparseable)';
  }
}

// Numeric-only comparison; prerelease tags are ignored on purpose.
function compareVersions(a, b) {
  const pa = String(a).split('-')[0].split('.').map(Number);
  const pb = String(b).split('-')[0].split('.').map(Number);
  for (let i = 0; i < 3; i += 1) {
    const x = pa[i] || 0;
    const y = pb[i] || 0;
    if (x !== y) return x < y ? -1 : 1;
  }
  return 0;
}

function classify(before, after) {
  const findings = [];
  const host = hostOf(after.resolved);

  if (after.resolved && !ALLOWED_HOSTS.has(host)) {
    findings.push({ level: 'HIGH', why: `resolved host ${host} is not on the allow-list` });
  }
  if (after.resolved && !after.integrity) {
    findings.push({ level: 'HIGH', why: 'entry has a resolved URL but no integrity token' });
  }
  if (PRIVATE_SCOPES.some((s) => after.name.startsWith(`${s}/`)) && host !== PRIVATE_HOST) {
    findings.push({ level: 'HIGH', why: `private scope resolving to ${host}` });
  }
  if (before) {
    if (before.version === after.version && before.integrity !== after.integrity) {
      findings.push({ level: 'HIGH', why: 'integrity changed while version stayed identical' });
    }
    if (compareVersions(after.version, before.version) < 0) {
      findings.push({ level: 'HIGH', why: `downgrade ${before.version} to ${after.version}` });
    }
    if (hostOf(before.resolved) !== host) {
      findings.push({ level: 'HIGH', why: `resolved host moved from ${hostOf(before.resolved)}` });
    }
  } else {
    findings.push({ level: 'MEDIUM', why: 'package is new in this pull request' });
  }
  return findings;
}

const base = loadFromRef(BASE_REF);
const head = indexLock(JSON.parse(readFileSync(LOCKFILE, 'utf8')));

const rows = [];
let high = 0;

for (const [key, after] of head) {
  const before = base.get(key) ?? null;
  const unchanged =
    before &&
    before.version === after.version &&
    before.resolved === after.resolved &&
    before.integrity === after.integrity;
  if (unchanged) continue;

  const findings = classify(before, after);
  high += findings.filter((f) => f.level === 'HIGH').length;
  rows.push([
    after.name,
    `${before ? before.version : '(new)'}${after.version}`,
    hostOf(after.resolved),
    before && before.integrity !== after.integrity ? 'yes' : 'no',
    findings.map((f) => `${f.level}: ${f.why}`).join('; ') || '—',
  ]);
}

for (const [key, before] of base) {
  if (!head.has(key)) {
    rows.push([before.name, `${before.version} → (removed)`, hostOf(before.resolved), 'n/a', 'removed']);
  }
}

console.log('| package | old → new | resolved host | integrity changed | notes |');
console.log('|---|---|---|---|---|');
for (const row of rows.sort((a, b) => a[0].localeCompare(b[0]))) {
  console.log(`| ${row.join(' | ')} |`);
}
console.log(`\n${rows.length} changed entries, ${high} high-severity findings.`);

process.exit(high > 0 ? 1 : 0);

The workflow that runs it does three separate things: it prints the table, it proves the lockfile reproduces, and it proves the manifest moved with it. Keep them as distinct steps so a failure names itself in the checks list.

# .github/workflows/lockfile-guard.yml
name: lockfile-guard

on:
  pull_request:
    paths:
      - 'package.json'
      - 'package-lock.json'

permissions:
  contents: read

jobs:
  guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Diff the lockfile against the base commit
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: node scripts/lockfile-diff.js "$BASE_SHA" | tee -a "$GITHUB_STEP_SUMMARY"

      - name: Install exactly what the lockfile pins
        run: npm ci --ignore-scripts

      - name: Assert npm ci did not rewrite the lockfile
        run: git diff --exit-code package-lock.json

fetch-depth: 0 is not optional — the default shallow clone has no base commit for git show to read.

Variants

Permalink to "Variants"

Assert the lockfile is reproducible

Permalink to "Assert the lockfile is reproducible"

npm ci installs strictly from package-lock.json and refuses to resolve anything new; if the manifest and the lockfile disagree it exits with an error instead of silently fixing the file. Running it and then asserting a clean tree turns “this lockfile is internally consistent” into a check that costs one line:

npm ci --ignore-scripts
git diff --exit-code package-lock.json

A dirty tree here means the file in the branch is not what the installer produces from the same inputs. The usual innocent cause is a different npm major version between the contributor’s machine and CI, which is worth pinning anyway; the interesting cause is a lockfile that was edited after generation. Which installer flag you standardise on across package managers is covered by npm ci vs pnpm --frozen-lockfile vs yarn --immutable. Keep --ignore-scripts on this job specifically: the point is to validate metadata, not to execute a candidate dependency’s lifecycle hooks on a runner that holds a checkout token.

Require a manifest change to accompany a lockfile change

Permalink to "Require a manifest change to accompany a lockfile change"

Most real dependency work touches both files. A pull request that rewrites the lockfile and leaves package.json untouched is not automatically hostile, but it is always worth a sentence of explanation:

      - name: Require a manifest change alongside the lockfile
        if: ${{ !contains(github.event.pull_request.labels.*.name, 'lockfile-only') }}
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          changed=$(git diff --name-only "$BASE_SHA"...HEAD)
          if echo "$changed" | grep -qx 'package-lock.json' \
             && ! echo "$changed" | grep -qx 'package.json'; then
            echo "::error::lockfile changed without package.json; add the lockfile-only label"
            exit 1
          fi

The lockfile-only label is the escape hatch, and it is deliberate: applying a label is an auditable action attributable to a person. Bot-authored dedupe and lockfile-maintenance pull requests from Automating Dependency Updates with Renovate can carry it automatically through the bot’s own label configuration.

Gate review on the lockfile paths with CODEOWNERS

Permalink to "Gate review on the lockfile paths with CODEOWNERS"

The check produces a table; CODEOWNERS makes sure someone qualified reads it. Put the file at .github/CODEOWNERS and list the lockfile paths explicitly:

# .github/CODEOWNERS
package.json               @acme/supply-chain
package-lock.json          @acme/supply-chain
**/package-lock.json       @acme/supply-chain
.github/workflows/         @acme/platform-security

CODEOWNERS by itself only requests reviews. It blocks nothing until the branch ruleset for your default branch has required pull request reviews with “Require review from Code Owners” enabled. Include the workflow directory in the ownership list as well, otherwise a pull request can weaken the guard and the lockfile in the same commit.

Pull request path through the lockfile guard A sequence diagram in which a contributor opens a pull request touching the lockfile, the pull_request event starts the guard job, the job runs npm ci and git diff, posts a signal table, and a code owner review is required before the merge is unblocked. Contributor Pull request lockfile guard job Code owner PR touches lockfile pull_request trigger npm ci + git diff signal table posted CODEOWNERS review required approve or request changes Merge stays blocked until every high-severity signal is cleared or explicitly acknowledged

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • A registry proxy legitimately rewrites the resolved host. If your organisation installs through a caching proxy, every resolved URL points at the proxy, not at registry.npmjs.org, and a naive allow-list fails the entire tree on day one. Put the proxy host on the allow-list and make the failure condition “not one of ours” rather than “not npm”. The proxy also becomes the single place worth hardening, as described in Configuring a Private npm Registry Proxy.

  • Some proxies repack tarballs and change the integrity token. A mirror that re-tars a package produces different bytes and therefore a different sha512- value for the same version. That collides with the “integrity changed, version did not” rule and will generate false positives on the first migration. Regenerate the lockfile in one clearly labelled commit when you switch registries, and treat the rule as absolute from that point on.

  • pull_request_target would hand a fork write access. It is tempting to reach for it so the job can comment on the pull request, but it runs the base branch’s workflow with a privileged token in the context of untrusted head code. Stay on pull_request and use the step summary or a separate, restricted job for commenting.

  • Blocking on lockfile diffs does not stop install-time code execution. The guard reasons about metadata. A package whose preinstall script runs on a developer machine has already won before any reviewer opens the diff, which is why this check pairs with Disabling npm Install Scripts rather than replacing it.

  • A matching integrity token proves delivery, not trustworthiness. Every field this check inspects tells you that you got the bytes the registry recorded. It says nothing about whether the maintainer account was compromised when those bytes were published. Attestation-based checks such as Verifying Sigstore Provenance for npm Packages answer the question this one cannot.

Verification Steps

Permalink to "Verification Steps"

1. Prove the script catches a host swap

Permalink to "1. Prove the script catches a host swap"

Edit one resolved URL in a scratch branch and run the script against your default branch:

node scripts/lockfile-diff.js origin/main; echo "exit: $?"

Expected output for a single tampered entry:

| package | old → new | resolved host | integrity changed | notes |
|---|---|---|---|---|
| lodash | 4.17.21 → 4.17.21 | evil.example.net | no | HIGH: resolved host evil.example.net is not on the allow-list; HIGH: resolved host moved from registry.npmjs.org |

1 changed entries, 2 high-severity findings.
exit: 1

2. Prove the reproducibility assertion fails on a hand edit

Permalink to "2. Prove the reproducibility assertion fails on a hand edit"

Change any integrity value by one character, then run the pair of commands from the workflow:

npm ci --ignore-scripts

npm ci aborts before the diff step with an integrity error naming the package, which is the outcome you want. Restore the file with git checkout -- package-lock.json afterwards.

3. Confirm the manifest guard triggers

Permalink to "3. Confirm the manifest guard triggers"

On a branch that touches only the lockfile, run the same comparison the workflow runs:

git diff --name-only origin/main...HEAD
# package-lock.json

With package.json absent from that list and no lockfile-only label, the step exits 1 and prints the annotation. Add the label and re-run the job to confirm the if: condition skips the step.

4. Confirm code owner review is actually required

Permalink to "4. Confirm code owner review is actually required"

Open a throwaway pull request that touches package-lock.json and check the merge box. The reviewers panel should list the owning team with a “Review required” state that the branch ruleset enforces; if it says “Review requested” with a green merge button, the ruleset is not requiring code owner approval yet.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does npm ci ever rewrite package-lock.json?

In normal operation it does not. npm ci installs strictly from the lockfile and, if package.json and the lockfile disagree, it exits with an error rather than resolving a new tree. That is what makes git diff --exit-code package-lock.json a useful assertion: a non-empty diff after the install means the file was hand-edited, generated by a different npm major version, or written by a tool that rewrote entries after resolution.

Why is an integrity change with no version change suspicious?

A published registry version is immutable, so the same name and version should always yield the same tarball and the same integrity token. When the version is byte-identical and the integrity differs, either the entry was edited by hand or the tree was resolved through a mirror that repacked the tarball. Both deserve an explanation before merge, and the second one should be a known, documented mirror.

Should a lockfile-only change always fail the check?

No. npm dedupe, npm audit fix and scheduled lockfile maintenance from an update bot all legitimately touch only the lockfile. Treat a lockfile change with no manifest change as a signal that needs an explicit acknowledgement — a label, or an approval from the owning team — rather than a hard failure that trains reviewers to bypass the job.

Does CODEOWNERS on its own stop lockfile tampering?

No. A CODEOWNERS entry only nominates reviewers; it blocks nothing until the branch ruleset requires review from code owners. Even then it puts a human in the path rather than removing the attack. Its value is that it guarantees the printed diff table reaches someone who knows what an unexpected resolved host looks like.

Do these signals apply to pnpm and Yarn lockfiles?

The signals do; the field names do not. pnpm-lock.yaml records a resolution block with an integrity value per package, and Yarn’s yarn.lock stores a checksum per entry. Point the same five questions at whichever fields your package manager writes, and keep one script per lockfile format rather than trying to parse all of them with shared code.

Permalink to "Related"

Related Articles

Parsing package-lock.json for Dependency Audits
Lockfile Mapping & Analysis Supply Chain Auditing & Depend…