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.
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.
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.
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
resolvedURL points at the proxy, not atregistry.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_targetwould 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 onpull_requestand 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
preinstallscript 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.
Related
Permalink to "Related"- Registry & Package Manager Hardening — the registry-side controls that shrink what a tampered lockfile entry can reach in the first place
- Pinning Transitive Dependencies in Monorepos — reducing the churn that makes lockfile diffs hard to review in the first place
- Verifying SLSA Build Provenance in CI — asserting where a package was built, not just that its bytes match the lockfile