How npm resolves dependencies โ and why your lockfile is the real attack surface
When you add a package to your Node project, npm installs not just that package but all of its dependencies, and their dependencies, recursively. This tree can be hundreds of packages deep. Your package.json only lists direct dependencies โ the version constraints you specify. The lockfile (package-lock.json or yarn.lock) records the exact resolved versions of every package in the entire tree.
The fundamental problem: SCA tools primarily report CVEs against your declared dependencies in package.json. But the packages that actually get installed โ and that run in your application โ are defined by the lockfile's resolution of the full transitive tree. These are different things, and the gap between them is where vulnerabilities hide.
In the tree above, your SCA tool sees express and some-library โ both clean. It may miss [email protected] and [email protected] entirely because they are three levels deep and not in your package.json.
The deceptive clean bill of health: npm audit is lockfile-aware and does scan transitive deps โ but it has its own gaps (see below). Many enterprise SCA tools that integrate with build systems or scan manifest files only look at declared dependencies. Always verify which mode your tool operates in.
The specific gap in SCA scanner coverage
Let's be specific about what different tools check:
Lockfile-aware tools (better)
- npm audit โ reads package-lock.json
- yarn audit โ reads yarn.lock
- OWASP Dependency-Check with full tree mode
- Snyk with --all-projects
- AquilaX SCA (scans full resolved tree)
Manifest-only tools (limited)
- Many SAST tools with "dependency detection"
- Simple CVE checkers reading package.json only
- pip-audit without pip freeze input
- Some SBOM generators missing transitive deps
- Any tool that doesn't read the lockfile
Even among lockfile-aware tools, there are edge cases:
The version resolution collision problem
Consider this scenario: you have lodash@^4.17.21 in your package.json (the patched version). But another direct dependency requires lodash@^4.17.0, and npm's deduplication algorithm resolves the tree to install [email protected] as a shared dependency because it satisfies both constraints. Your package.json says 4.17.21+, but your node_modules gets 4.17.20.
# Don't trust package.json. Check what's actually installed: npm ls lodash # Example output showing version conflict: [email protected] โโโฌ [email protected] โ โโโ [email protected] # โ not the version you pinned! โโโ [email protected] # your direct dep, correct # Check which version code actually imports at runtime: node -e "console.log(require('lodash/package.json').version)" # May output 4.17.20 depending on resolution order # List ALL installed versions of a package across the tree: npm ls --all | grep lodash # See the full flattened dependency tree (can be thousands of lines): npm ls --all --json | jq '.dependencies | keys'
The "fixed" package that isn't
Here is a scenario we see regularly: a CVE is reported in [email protected] (CVE-2022-0536, SSRF via host header). A developer runs npm audit fix, which updates axios in their direct dependencies to 0.27.2. The audit comes back clean.
But they have two other packages that each bring in their own copy of [email protected] as a transitive dependency. npm audit fix can only fix direct dependencies by default; it cannot forcibly upgrade transitive dependencies without potentially breaking semver contracts for the packages that depend on them.
# Show every installation of axios and its version npm ls axios --all # More complete: search node_modules directly find node_modules -name "package.json" -path "*/axios/package.json" \ -exec grep -H '"version"' {} \; # With jq โ get package name + version for all installed packages find node_modules -name "package.json" -not -path "*/node_modules/*/node_modules/*" \ | xargs jq -r '[.name, .version] | @tsv' 2>/dev/null \ | sort | uniq # Then cross-reference against a known-bad version list find node_modules -name "package.json" -not -path "*/node_modules/*/node_modules/*" \ | xargs jq -r '[.name, .version] | @tsv' 2>/dev/null \ | grep -E 'axios\t0\.(19|20|21)\.' # CVE range
Real CVEs that persist via transitive paths
These are not theoretical. All of these were found lingering in production codebases years after the direct dependency was "updated."
CVE-2022-0536 โ axios SSRF via follow-redirects
Axios versions before 0.25.0 used follow-redirects without sanitizing the Host header during redirects, enabling SSRF in server-side axios calls. Many packages that depended on axios specified ^0.21.x in their own package.json and were slow to update. If you had one of those packages as a transitive dep, you got the vulnerable axios version regardless of what you pinned directly.
CVE-2021-23337 โ lodash command injection via template
Lodash's _.template() function executed template strings with arbitrary JavaScript when variable option was not set. If any part of your call chain passed user-controlled input to _.template, this was RCE. Lodash is one of the most widely used transitive dependencies in the npm ecosystem โ it shows up in hundreds of thousands of packages. Even after upgrading to 4.17.21 directly, transitive lodash copies at 4.17.20 and earlier remained.
CVE-2019-10744 โ lodash prototype pollution via defaultsDeep
_.defaultsDeep, _.merge, and _.mergeWith allowed prototype pollution when processing malicious input objects. This CVE affects lodash versions prior to 4.17.12. Any application doing _.merge(config, userInput) anywhere in the call chain โ including in framework internals โ was vulnerable. The fix required upgrading every copy of lodash in the entire dependency tree, not just the direct dependency.
# Audit package-lock.json for specific CVE-affected versions # This finds lodash < 4.17.12 in the lockfile node -e " const lock = require('./package-lock.json'); function findVulnerable(deps, parentPath = '') { if (!deps) return; for (const [name, info] of Object.entries(deps)) { const fullPath = parentPath ? parentPath + ' > ' + name : name; if (name === 'lodash') { const v = info.version.split('.').map(Number); const vulnerable = v[0] === 4 && v[1] === 17 && v[2] < 12; if (vulnerable) console.log('VULNERABLE:', fullPath, info.version); } findVulnerable(info.dependencies, fullPath); } } findVulnerable(lock.dependencies); " # Or use npm ls to show the dependency path to a specific package npm ls lodash@'<4.17.12' # shows all instances below the patched version
The deduplication lottery: npm's hoisting algorithm means that whether you get the vulnerable version or the safe version depends on the order packages were installed and the specific semver ranges declared by your transitive dependencies. This makes the vulnerability non-deterministic across installs if lockfiles aren't committed โ two developers on the same project may have different vulnerability surfaces.
Dependency confusion: when the package name itself is the attack
Dependency confusion attacks (popularised by Alex Birsan's 2021 research) exploit how npm resolves package names when both a private registry and the public npm registry are configured. When npm sees a package name, it checks both registries and installs the one with the higher version number.
The attack: an attacker finds the name of your private internal package (often leaked in error messages, package.json files in public repos, or job postings), then publishes a package with the same name on the public npm registry at a very high version number (e.g., 99.0.0). npm will install the attacker's package instead of your internal one, and their code runs in your CI/CD pipeline or production environment.
# VULNERABLE: internal packages can be confused with public ones # This config falls back to public npm for any package registry=https://npm.pkg.github.com/your-org # No scoped registry โ so @your-org/internal-lib might resolve from public npm # SAFER: scope your private packages with a namespace # and point that scope exclusively at your private registry @your-org:registry=https://npm.pkg.github.com/your-org always-auth=true # For other packages, use public npm # The scope prevents confusion โ public npm has no @your-org packages # ALSO: check your package.json for unscoped internal package names # If you have "internal-auth-lib" (no scope), it can be confused # Always use "@your-org/internal-auth-lib" for private packages
Internal package names in public places: Check your error logs, public GitHub repos, Docker image labels, and job descriptions. Attackers actively scrape these for internal package names to register on npm. Any unscoped package name that appears in a package.json and is not published on the public npm registry is a dependency confusion candidate.
Auditing your actual dependency tree
Here is a practical workflow for auditing the real installed package set, not just what's in package.json:
# Step 1: npm audit โ lockfile-aware, covers transitive deps npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.severity == "high" or .value.severity == "critical") | {name: .key, severity: .value.severity, via: .value.via}' # Step 2: Force a clean install and re-audit # (catches cases where lockfile and node_modules are out of sync) rm -rf node_modules npm ci # install exactly from lockfile npm audit # Step 3: Check for multiple installed copies of the same package npm ls --all --json | python3 -c " import json, sys tree = json.load(sys.stdin) def collect_versions(deps, result={}): for name, info in (deps or {}).items(): result.setdefault(name, set()).add(info.get('version', 'unknown')) collect_versions(info.get('dependencies'), result) return result versions = collect_versions(tree.get('dependencies', {})) dupes = {k: sorted(v) for k, v in versions.items() if len(v) > 1} for pkg, vers in sorted(dupes.items()): print(f'{pkg}: {vers}') " # Step 4: OWASP Dependency-Check for comprehensive CVE matching docker run --rm \ -v $(pwd):/src \ -v $(pwd)/odc-reports:/report \ owasp/dependency-check \ --project "my-app" \ --scan /src \ --format JSON \ --out /report
name: SCA โ Full Dependency Audit on: [push, pull_request] jobs: sca: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install dependencies (from lockfile) run: npm ci - name: npm audit (transitive-aware) run: npm audit --audit-level=high # fails build on high/critical in ANY package, transitive included - name: Check for duplicate package versions run: | npm ls --all 2>&1 | grep -E 'WARN.*invalid|deduped' || true # Flag any packages with multiple installed versions - name: Snyk โ deep transitive scan uses: snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: args: --all-projects --severity-threshold=high
Python and pip: the lockfile problem
Python's package management makes this problem significantly worse by default. pip install -r requirements.txt without pinned versions does not produce deterministic installs โ different environments can end up with different versions of transitive dependencies. And unlike npm, pip does not have a universal lockfile format baked into the toolchain.
# requirements.txt โ what most teams have django>=4.0 requests>=2.28 celery>=5.0 # Problem: running pip install at different times gives different versions # of these packages AND their transitive deps (urllib3, certifi, kombu, etc) # What you actually need: pip freeze output as requirements-lock.txt pip freeze > requirements-lock.txt # Install from the lock: pip install -r requirements-lock.txt # Better: use pip-tools pip install pip-tools pip-compile requirements.in # generates requirements.txt with all transitive deps pinned pip-sync requirements.txt # installs exactly those versions # Or use Poetry which has a proper lockfile poetry add django requests celery # creates poetry.lock with full transitive tree poetry install --no-root # installs exactly from lockfile
# pip-audit โ lockfile-aware Python CVE scanner pip install pip-audit # Audit current environment (all installed packages, including transitive) pip-audit # Audit from a requirements file pip-audit -r requirements.txt # Audit with OSV (Open Source Vulnerabilities) database pip-audit --vulnerability-service osv -r requirements.txt # Show full dependency path to vulnerable package pip-audit --desc=on -r requirements.txt # Safety โ alternative tool pip install safety safety check --full-report
PyPI does not prevent squatting: Like npm, PyPI packages can be registered by anyone. The dependency confusion attack applies equally. Always scope your internal Python packages and configure pip to look for them first (via --extra-index-url with --index-url set to your private registry).
Fixes and mitigations
1. Always commit your lockfile
Never add package-lock.json or yarn.lock to .gitignore. These files are your source of truth for what actually gets installed. Without them, every CI run and every developer install can resolve to different transitive versions โ including vulnerable ones that weren't present when you last ran your SCA scan.
2. Use npm ci instead of npm install in CI
npm ci installs exactly the versions specified in package-lock.json and fails if the lockfile is out of sync with package.json. This is what you want in CI โ deterministic, lockfile-driven installs with no version resolution surprises.
3. Audit the lockfile, not package.json
Make sure your SCA tool reads the lockfile. If it's only scanning package.json or requirements.txt, you're getting an incomplete picture. Ask your SCA vendor explicitly: "Does this tool scan the full resolved dependency tree including transitive dependencies?"
4. Scope all private packages
Use scoped package names (@your-org/package-name) for all internal packages and point that scope at your private registry. This eliminates dependency confusion for those packages entirely โ the public npm registry has no packages in your scope.
5. Use npm overrides (npm 8.3+) or Yarn resolutions to force safe versions
{
"overrides": {
// Force lodash to safe version everywhere in the tree
"lodash": ">=4.17.21",
// Force follow-redirects to safe version (axios SSRF fix)
"follow-redirects": ">=1.15.4",
// Nested override: only for axios's follow-redirects dep
"axios": {
"follow-redirects": ">=1.15.4"
}
}
}Overrides can break things: Forcing a higher version of a transitive dependency may break the package that depends on it if that package's code is incompatible with the newer API. Test thoroughly after adding overrides. They are a useful short-term fix but not a substitute for proper upstream patching.
Scan the tree you actually ship, not just what's in package.json
AquilaX SCA reads your lockfile and scans the full resolved dependency tree โ every transitive package, every version, every CVE path. No false sense of security from manifest-only scanning.