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.


package-lock.json Audit Parse Flow Diagram showing the audit pipeline: read package-lock.json, detect lockfileVersion (1, 2, or 3), route to the correct parser, extract resolved URL and integrity hash per package, validate the hash format, then either pass to CI or fail the build on violation. Read package-lock.json Detect lockfileVersion v1 dependencies tree 2+ packages map (flat, hoisted) Extract per pkg: resolved, integrity version, dev flag Validate hash format + URL Pass → CI violation Fail → block build

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:

lockfileVersion Capability Matrix A four-row matrix comparing lockfileVersion 1, 2 and 3: version 1 carries only the authoritative nested dependencies tree, version 2 carries a legacy copy of that tree plus the preferred flat packages map, and version 3 carries the packages map as its only source. The final row shows that a parser should read dependencies for version 1 and packages for versions 2 and 3. Schema element lockfileVersion 1 lockfileVersion 2 lockfileVersion 3 dependencies tree authoritative legacy copy absent packages map absent present, preferred only source written by npm 5 and 6 npm 7 and 8 npm 9 and later parser should read dependencies packages packages
// 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:

Anatomy of a packages Map Entry A single package-lock.json packages entry shown as JSON on the left, with four arrows pointing to the audit check each field feeds: version drives pin and drift checks, resolved drives the registry allow-list, integrity drives the SRI token format check, and dev decides whether the package is excluded from the production audit. "node_modules/left-pad": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/…", "integrity": "sha512-BOZ0…7uSg==", "dev": false } version → pin and drift checks resolved → registry allow-list integrity → token format check dev → excluded from prod audit

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 integrity field in package-lock.json is a SHA-512 hash of the .tgz tarball 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 fresh sha384- hashes over the built artifact for actual integrity attributes on <script> and <link> tags.

  • v2 lockfiles contain two overlapping representations. lockfileVersion: 2 includes both packages (the modern flat map) and dependencies (the legacy nested tree). Prefer packages for 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, while packages["node_modules/some-dep"] is a hoisted production package. Iterating only node_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 no integrity field. 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 packages but may be marked peerDependency: true. Ensure your inventory includes them; they represent real runtime code even if no direct require() 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.

Permalink to "Related"

Related Articles

Detecting Lockfile Tampering in Pull Requests
Lockfile Mapping & Analysis Supply Chain Auditing & Depend…