Parsing package-lock.json for Dependency Audits
Permalink to "Parsing package-lock.json for Dependency Audits"Part of Lockfile Mapping & Analysis, this page covers exactly how to read package-lock.json across all three schema versions, extract cryptographic integrity fields, handle workspace monorepos, and feed the results into audit and SRI generation pipelines.
Quick Reference
Permalink to "Quick Reference"| Property | Value |
|---|---|
| File | package-lock.json |
| Schema versions | lockfileVersion 1, 2, 3 |
| Integrity field format | sha512-<base64> (npm default) |
| Integrity covers | Registry tarball (.tgz), not the unpacked JS file |
| Parsing entry point (v1) | root.dependencies (nested tree) |
| Parsing entry point (v2/v3) | root.packages (flat map) |
| Missing integrity handling | Treat as policy violation; require explicit allow-list |
| Browser SRI algorithm | Compute fresh sha384- over the built artifact |
Why package-lock.json Matters for Auditing
Permalink to "Why package-lock.json Matters for Auditing"When npm installs a package, it downloads a tarball from the registry and records a cryptographic checksum of that tarball in package-lock.json under the integrity key. This checksum is your first line of defense: it lets any subsequent npm ci run verify that the exact same bytes were delivered by the registry. If an attacker poisons the registry after your initial install — a technique sometimes called a dependency confusion or substitution attack — the integrity mismatch causes the install to fail.
Automating that verification at the source, before installation runs, is the goal of lockfile auditing. A parser that reads these fields and flags anomalies (missing hashes, unexpected registry URLs, algorithm downgrades) gives you an auditable signal earlier in the pipeline than npm audit alone can provide. This is the foundation layer of the broader Supply Chain Auditing & Dependency Verification workflow.
The parsed record set is also the input a review-time check needs: Detecting Lockfile Tampering in Pull Requests works by diffing the structured output of a parser like the one below across two commits, which surfaces a silently rewritten resolved host or a downgraded hash algorithm that a raw JSON diff of several thousand lines will bury.
Canonical Implementation
Permalink to "Canonical Implementation"The following Node.js module handles all three lockfile schema versions, extracts the full dependency inventory, and returns a flat array of records ready for validation or SRI generation. The version router exists because each schema carries a different set of structures, and only one of them is authoritative in any given file:
// lockfile-audit.js
import { readFileSync } from 'fs';
/**
* Parse a package-lock.json file and return a flat array of dependency records.
* Each record: { name, version, resolved, integrity, dev }
*/
export function parseLockfile(lockfilePath = 'package-lock.json') {
const raw = readFileSync(lockfilePath, 'utf8');
const lock = JSON.parse(raw);
const version = lock.lockfileVersion ?? 1;
if (version >= 2 && lock.packages) {
return extractPackagesMap(lock.packages);
}
// v1 fallback
return extractDependenciesTree(lock.dependencies ?? {}, '');
}
function extractPackagesMap(packages) {
const results = [];
for (const [key, meta] of Object.entries(packages)) {
if (key === '') continue; // skip root package entry
const name = key.replace(/^node_modules\//, '').replace(/\/node_modules\//g, '/');
results.push({
name,
version: meta.version ?? '',
resolved: meta.resolved ?? null,
integrity: meta.integrity ?? null,
dev: meta.dev ?? false,
});
}
return results;
}
function extractDependenciesTree(deps, prefix) {
const results = [];
for (const [name, meta] of Object.entries(deps)) {
results.push({
name: prefix ? `${prefix}/${name}` : name,
version: meta.version ?? '',
resolved: meta.resolved ?? null,
integrity: meta.integrity ?? null,
dev: meta.dev ?? false,
});
if (meta.dependencies) {
results.push(...extractDependenciesTree(meta.dependencies, name));
}
}
return results;
}
// --- Validation ---
const SRI_PATTERN = /^(sha256|sha384|sha512)-[A-Za-z0-9+/]+=*$/;
export function validateRecord(record) {
const errors = [];
if (!record.integrity) {
errors.push(`Missing integrity field for ${record.name}@${record.version}`);
} else if (!SRI_PATTERN.test(record.integrity)) {
errors.push(`Malformed integrity value for ${record.name}: "${record.integrity}"`);
}
if (!record.resolved) {
errors.push(`Missing resolved URL for ${record.name}@${record.version}`);
}
return errors;
}
// --- CLI entry point ---
const deps = parseLockfile();
const violations = deps.flatMap(validateRecord);
if (violations.length > 0) {
for (const v of violations) console.error('[FAIL]', v);
process.exit(1);
}
console.log(`[OK] ${deps.length} packages validated`);
Run it in CI before any compilation step:
node lockfile-audit.js
# [OK] 312 packages validated
# — or —
# [FAIL] Missing integrity field for [email protected]
# exits with code 1, blocking the pipeline
Variant Examples
Permalink to "Variant Examples"Filtering production-only dependencies
Permalink to "Filtering production-only dependencies"Strip dev: true entries to reduce the audit surface to what actually ships to users:
const prodDeps = parseLockfile().filter(d => !d.dev);
console.log(`${prodDeps.length} production dependencies to audit`);
This is particularly useful before passing the list to a vulnerability scanner or generating the bill-of-materials component list for Automated SBOM Generation pipelines.
Cross-referencing with the npm registry API
Permalink to "Cross-referencing with the npm registry API"For each resolved package, you can verify that the registry still returns the same integrity token, catching post-publish tampering:
import { createHash } from 'crypto';
async function verifyAgainstRegistry(name, version, expectedIntegrity) {
const res = await fetch(`https://registry.npmjs.org/${name}/${version}`);
const meta = await res.json();
const dist = meta?.dist;
if (!dist?.integrity) throw new Error(`No registry integrity for ${name}@${version}`);
if (dist.integrity !== expectedIntegrity) {
throw new Error(
`Integrity mismatch for ${name}@${version}:\n` +
` lockfile: ${expectedIntegrity}\n` +
` registry: ${dist.integrity}`
);
}
return true;
}
Run this check as a nightly job against your full production dependency list. Any divergence is a signal worth immediate investigation under your Provenance Verification Workflows.
Generating a registry-source allow-list
Permalink to "Generating a registry-source allow-list"Enforce that every resolved URL comes from an approved registry domain. This blocks dependency confusion payloads that resolve to attacker-controlled registries:
const ALLOWED_ORIGINS = ['https://registry.npmjs.org/', 'https://npm.pkg.github.com/'];
export function enforceRegistryPolicy(deps) {
const violations = deps.filter(d => {
if (!d.resolved) return true; // missing = violation
return !ALLOWED_ORIGINS.some(origin => d.resolved.startsWith(origin));
});
return violations;
}
Keeping this list short is the point: every extra origin is another publisher whose compromise becomes your compromise. Where every install already flows through one upstream, Configuring a Private npm Registry Proxy collapses the allow-list to a single entry and gives you one place to quarantine a package the moment an advisory lands.
Each of the checks above consumes a different field of the same entry, so it helps to read one packages record as an annotated whole rather than as loose keys:
The map key carries information too. node_modules/left-pad is a hoisted top-level install, while node_modules/foo/node_modules/left-pad is a nested copy pinned to a different version by a conflicting range — the same package name appearing under two keys with two integrity values is normal, and an inventory that deduplicates by name alone will report only one of them.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
Tarball integrity ≠ browser SRI hash. The
integrityfield inpackage-lock.jsonis a SHA-512 hash of the.tgztarball downloaded from the registry. The browser cannot verify a tarball hash — it verifies the bytes of the final JavaScript file after your bundler has unpacked, processed, and concatenated it. Always compute freshsha384-hashes over the built artifact for actualintegrityattributes on<script>and<link>tags. -
v2 lockfiles contain two overlapping representations.
lockfileVersion: 2includes bothpackages(the modern flat map) anddependencies(the legacy nested tree). Preferpackagesfor authoritative data — the legacy tree exists only for backward compatibility with older npm versions and may omit some fields present in the flat map. -
Workspace entries appear under two key patterns. In a monorepo,
packages["packages/my-app"]is the workspace member, whilepackages["node_modules/some-dep"]is a hoisted production package. Iterating onlynode_modules/keys silently skips workspace-local overrides that may shadow registry versions. -
File, git, and link protocol entries rarely carry integrity. Dependencies declared as
"mylib": "file:../mylib","mylib": "github:org/repo#abc123", or workspace symlinks typically have nointegrityfield. Your validator must explicitly allow-list these entries rather than silently skipping them — silent skips create audit blind spots. -
Peer dependency phantom installs. npm 7+ automatically installs peer dependencies, which appear in
packagesbut may be markedpeerDependency: true. Ensure your inventory includes them; they represent real runtime code even if no directrequire()references them.
Verification Steps
Permalink to "Verification Steps"1. Confirm version detection
Permalink to "1. Confirm version detection"Print the detected schema version to validate your router logic before running on real data:
node -e "
const lock = JSON.parse(require('fs').readFileSync('package-lock.json','utf8'));
console.log('lockfileVersion:', lock.lockfileVersion);
console.log('has packages:', !!lock.packages);
console.log('has dependencies:', !!lock.dependencies);
"
# lockfileVersion: 3
# has packages: true
# has dependencies: false
2. Count extracted records against installed packages
Permalink to "2. Count extracted records against installed packages"The record count from the parser should match what npm reports as installed:
# Count packages extracted by the parser
node -e "
import('./lockfile-audit.js').then(m => {
const deps = m.parseLockfile();
console.log('Extracted:', deps.length);
console.log('Missing integrity:', deps.filter(d => !d.integrity).length);
});
"
# Cross-check with npm
npm ls --all --json 2>/dev/null | node -e "
const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
let count=0; const walk=o=>{count++;for(const v of Object.values(o.dependencies||{}))walk(v);}; walk(d); console.log('npm ls count:', count-1);
"
Both numbers should be within a few units of each other (differences arise from deduplication and workspace entries).
3. CI gate output
Permalink to "3. CI gate output"When the validator detects a missing integrity field, it exits non-zero and prints structured output. This is the expected failure signal in CI:
[FAIL] Missing integrity field for [email protected]
[FAIL] Malformed integrity value for [email protected]: "sha1-abc..."
A sha1- prefix is itself a policy violation — SHA-1 does not meet current supply chain security standards. Map the SHA-1 integrity finding to a dependency update in your Vulnerability Tracking & Triage backlog.
4. Validate the full audit pipeline end-to-end
Permalink to "4. Validate the full audit pipeline end-to-end"Wire the lockfile parser as the first step in your CI sequence, then pass its output to downstream steps:
# .github/workflows/supply-chain-audit.yml
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Validate lockfile integrity fields
run: node lockfile-audit.js --strict
- name: Install (deterministic)
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Generate SRI manifest
run: node scripts/generate-sri-hashes.js --dir dist/ --out dist/sri-manifest.json
- name: Verify SRI tokens in HTML
run: node scripts/verify-sri.js --manifest dist/sri-manifest.json --html dist/index.html
The lockfile audit step must gate all subsequent steps. If it exits non-zero, npm ci never runs and no build artifact reaches the CDN. The --ignore-scripts flag on the install step is load-bearing rather than decorative: without it, a preinstall hook in any of the packages you just finished verifying executes with full write access to the runner before your build even starts, which is why Disabling npm Install Scripts is better set as a repository-wide default in .npmrc than remembered per command.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Can I use the integrity values from package-lock.json directly as SRI attributes?
No. The integrity field in package-lock.json is a hash of the registry tarball (.tgz), not of the unpacked JavaScript file the browser loads. You must compute fresh hashes over the final bundled artifact that your build pipeline produces and deploys.
What is the difference between lockfileVersion 1, 2, and 3?
Version 1 stores the dependency graph as a nested ‘dependencies’ tree. Version 2 adds a flat ‘packages’ map alongside the legacy tree for backward compatibility. Version 3 drops the legacy tree entirely and uses only the ‘packages’ map. Always read the lockfileVersion integer before parsing.
Why do some entries in package-lock.json have no integrity field?
Packages installed with very old npm versions, file: path overrides, git dependencies, and workspace symlinks may omit the integrity field. Treat missing integrity fields as policy violations in security-critical pipelines — require explicit allow-listing for any such entry.
How do workspace monorepos affect lockfile parsing?
npm hoists workspace packages into the root packages map under ‘node_modules/package-name’ keys and adds workspace members as ‘packages/member-name’ entries. You must traverse both sets to build a complete dependency inventory; naive top-level iteration misses workspace-local resolutions.
Related
Permalink to "Related"- Lockfile Mapping & Analysis — parent page covering the full dependency mapping workflow, CI/CD gating, and compliance reporting
- Detecting Lockfile Tampering in Pull Requests — turning the parsed record set into a review-time diff that catches swapped registry hosts and downgraded hash algorithms
- Automated SBOM Generation — converting the extracted dependency inventory into a CycloneDX or SPDX bill-of-materials for regulatory reporting
- Provenance Verification Workflows — extending lockfile integrity checks with Sigstore signatures and SLSA provenance attestations