Auditing Transitive Deps That Break Fresh Installs
Every existing developer's install works, but a new hire's npm ci fails with npm error notarget No matching version found for @acme/telemetry@^2.4.1, a fresh pip install -r requirements.txt pulls urllib3 2.x and breaks with ImportError: cannot import name 'DEFAULT_CIPHERS', or bundle install resolves a different nested gem version than anyone else has. Old machines have cached, resolved trees; a fresh clone resolves from scratch against today's registry. The difference lives in transitive dependencies — the dependencies of your dependencies — that nobody chose directly. This page finds them and locks them down, as part of dependency tree visualization.
The most reliable way to catch these failures is to do what a new hire does: install from nothing, on a clean machine, regularly.
Diagnostic
Install from scratch in a clean container and compare the resolved tree with what the lockfile says:
#!/usr/bin/env bash
set -euo pipefail
docker run --rm -v "$PWD":/src:ro -w /tmp/app node:20-bookworm-slim sh -c '
cp -r /src/package.json /src/package-lock.json . && npm ci --no-audit --no-fund 2>&1 | tail -3'
git diff --stat package-lock.json
docker run --rm -v "$PWD":/src:ro -w /tmp/app python:3.12-slim sh -c '
cp /src/requirements.txt . && pip install -q -r requirements.txt 2>&1 | tail -2; pip freeze | grep -i urllib3'
grep -nE '^[a-zA-Z0-9_.-]+$' requirements.txt | head -5 || echo "all requirements pinned"
Expected bad output:
npm error notarget No matching version found for @acme/telemetry@^2.4.1.
npm error notarget In most cases you or one of your dependencies are requesting
npm error notarget a package version that doesn't exist.
urllib3==2.2.3
3:requests
7:boto3
The npm lockfile references a version that was unpublished from the internal registry, and the Python requirements list top-level packages without versions, so urllib3 floats to a major version the code does not support.
Root cause
Package managers resolve a dependency tree from version ranges. Direct dependencies are usually visible and reviewed; transitive ones are chosen by the resolver based on whatever the registry offers at install time. A lockfile freezes the whole tree, but only if it exists, is committed, and is honoured — npm ci honours package-lock.json, npm install may update it; pip install -r requirements.txt with unpinned lines has no lock at all. Existing machines hide the problem because they installed months ago and keep their resolved tree in node_modules or a virtualenv. A fresh clone resolves against the registry now, where a transitive dependency may have released a breaking version, been yanked, or been removed from an internal mirror. The failure appears only for new hires and clean CI caches, which is why it is so often misread as "their machine is broken".
Resolution
- Lock every ecosystem completely and commit the lockfile:
#!/usr/bin/env bash
set -euo pipefail
npm install --package-lock-only
python3 -m pip install --quiet pip-tools==7.4.1
pip-compile --generate-hashes --output-file requirements.lock requirements.in
git add package-lock.json requirements.in requirements.lock
requirements.in lists direct dependencies; pip-compile resolves and pins the full tree, with hashes, into requirements.lock.
Install only from lockfiles in bootstrap scripts, Dockerfiles and CI —
npm ci,pip install --require-hashes -r requirements.lock,bundle install --frozen— so nothing resolves at install time.Find what pulled a problem package in with the ecosystem's tree tools:
#!/usr/bin/env bash
set -euo pipefail
npm ls @acme/telemetry --all 2>/dev/null | head -10
python3 -m pip install --quiet pipdeptree==2.23.1 && pipdeptree --reverse --packages urllib3
The reverse tree shows which direct dependency requires the transitive package, which is the one to upgrade, pin or override.
- Override a bad transitive version when the direct dependency has not released a fix:
{
"overrides": {
"@acme/telemetry": "2.4.3"
}
}
npm overrides (in package.json) force a version anywhere in the tree; in Python, add a constraint line to requirements.in (urllib3<2) and recompile.
- Mirror what you depend on. If an internal registry proxies public packages, configure it to cache rather than proxy-through, so an unpublished upstream version does not break installs.
Expected output
$ docker run --rm -v "$PWD":/src:ro -w /tmp/app node:20-bookworm-slim sh -c 'cp /src/package*.json . && npm ci --no-audit --no-fund | tail -1'
added 1412 packages in 38s
$ docker run --rm -v "$PWD":/src:ro -w /tmp/app python:3.12-slim sh -c 'cp /src/requirements.lock . && pip install -q --require-hashes -r requirements.lock && pip freeze | grep urllib3'
urllib3==1.26.20
$ git diff --stat package-lock.json
$
A clean container installs exactly the locked tree for both ecosystems, and the lockfile is unchanged by the install.
That last point is the check that separates a real lockfile from a decorative one. If a clean install modifies the lockfile, some version was resolved at install time, and the next new hire may get a different tree again. npm ci fails outright in that case, which is the behaviour you want; for other ecosystems, a git diff --exit-code on the lockfile after installing in CI provides the same guarantee.
Prevention
- Run a scheduled clean install in CI with no caches, in a fresh container, nightly. It fails the day a transitive dependency breaks rather than the day a new hire joins:
name: fresh-install
on:
schedule: [{ cron: '0 5 * * *' }]
jobs:
install:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: docker run --rm -v "$PWD":/src:ro -w /tmp/app node:20-bookworm-slim sh -c 'cp /src/package*.json . && npm ci'
- run: docker run --rm -v "$PWD":/src:ro -w /tmp/app python:3.12-slim sh -c 'cp /src/requirements.lock . && pip install --require-hashes -r requirements.lock'
Reject unpinned requirements in review: a CI grep that fails on lines in lockfiles or
requirements.lockwithout==.Update dependencies deliberately with Renovate or Dependabot pull requests, so lockfile changes are reviewed and tested instead of happening implicitly during someone's install.
Platform caveats
Apple Silicon (ARM64): lockfiles pin versions, not platform binaries; a locked package can still lack an arm64 build. The clean-install job should run on both architectures if the team uses both — see diagnosing native module build failures on ARM64.
Monorepos: workspace managers (npm, pnpm, Yarn) keep one lockfile at the root; running installs in sub-packages can create stray lockfiles. See resolving duplicate lockfile versions in a monorepo.
Windows (native):
pip-compile --generate-hashesoutput is platform-neutral for pure-Python packages but may list platform-specific wheels; compile on Linux in CI if production runs Linux.
Rollback
Lockfiles and overrides are ordinary files; revert them to return to range-based resolution:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- package.json package-lock.json requirements.txt
git rm --cached -q requirements.in requirements.lock 2>/dev/null || true
Frequently Asked Questions
Why does the install fail for a new hire but work for everyone else?
Everyone else installed months ago and still has a resolved tree on disk. The new hire resolves from scratch against today's registry, where a transitive package changed or disappeared. A committed lockfile and lockfile-only installs remove the difference.
What is the difference between npm install and npm ci?
npm ci installs exactly what package-lock.json specifies and fails if it does not match package.json; npm install may update the lockfile. Use npm ci everywhere except when intentionally changing dependencies.
How do I find which dependency pulled in a broken package?
Use a reverse tree: npm ls <package> --all for Node, pipdeptree --reverse --packages <package> for Python, bundle exec gem dependency -R <gem> for Ruby.
Are overrides safe to keep long term?
They are a stopgap. Keep a comment or issue explaining each override and remove it once the direct dependency releases a fixed version, or it may pin a vulnerable version forever.