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.


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 v2/v3 packages map (flat, hoisted) Extract per pkg: resolved, integrity version, dev flag Validate hash format + URL Pass → CI Fail → block build violation

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.

// 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;
}

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.


Permalink to "Related"
Lockfile Mapping & Analysis Supply Chain Auditing & Depend…