Lockfile Mapping & Analysis

Permalink to "Lockfile Mapping & Analysis"

Lockfile mapping is part of Supply Chain Auditing & Dependency Verification. Without it, semantic versioning ranges in package.json silently resolve to different versions across developer machines, CI runners, and production builds — a gap that supply chain attackers exploit by publishing malicious patch releases timed to hit the next npm install.

This page covers the full workflow: parsing lockfiles across npm, Yarn, and pnpm ecosystems; validating every resolved entry against its stored cryptographic hash; generating Subresource Integrity hashes for browser-delivered assets; and wiring the verification step into CI/CD pipelines as a blocking gate.


Prerequisites

Permalink to "Prerequisites"

Conceptual Foundation

Permalink to "Conceptual Foundation"

A lockfile is a deterministic snapshot of the full resolved dependency graph, including every transitive dependency, its exact version, the registry URL that the package manager fetched it from, and a cryptographic integrity hash of the downloaded tarball. The npm v3 format stores SHA-512 digests in an integrity field using the W3C Subresource Integrity encoding (sha512-<base64>). Yarn Berry uses the same format. Yarn Classic uses a SHA-1 hex string. pnpm stores SHA-512 with a full tarball URL.

The security property these hashes provide is identical to the integrity attribute on <script> and <link> elements: the package manager (or your validation script) computes the digest of the bytes it received and compares it to the recorded value. Any modification — whether from a compromised registry, a man-in-the-middle network, or a tampered local cache — produces a mismatch that must halt the pipeline.

Critically, the lockfile hash covers the tarball, not the installed files. For browser-delivered assets you need a second hash pass: compute SHA-384 (the industry-standard algorithm for SRI) over the final minified and bundled output files.


Lockfile-to-SRI pipeline Data flow from package.json manifest through lockfile resolution, integrity validation, SRI hash generation, and CI/CD gating to produce a verified deployment artifact, with a dashed failure path halting the pipeline on a hash mismatch. package.json semver ranges (≥1.2.0, ^4.x) Lockfile exact versions sha512-abc… registry URLs resolved tree npm / Yarn / pnpm Hash Validation re-download tarballs compute SHA-512 compare to lockfile validate registry URL lockfile-lint / custom SRI Hash Gen bundle output files compute SHA-384 build sri-manifest inject into HTML openssl / webpack CI/CD Gate block on mismatch emit SBOM artifact mismatch pipeline halt + alert

Step 1: Extract the Resolved Dependency Graph

Permalink to "Step 1: Extract the Resolved Dependency Graph"

Parse the lockfile to obtain the full flat map of packageName@version → { integrity, resolved } entries. For npm v3 lockfiles (package-lock.json format version 3) the packages object is the canonical source; the legacy dependencies key is kept for backward compatibility only.

// scripts/extract-lockfile-map.js
import { readFileSync } from 'node:fs';

const lock = JSON.parse(readFileSync('package-lock.json', 'utf8'));
// lockfileVersion 3 uses the "packages" key; version 1/2 uses "dependencies"
const source = lock.lockfileVersion >= 3 ? lock.packages : flattenDeps(lock.dependencies);

const depMap = {};
for (const [key, entry] of Object.entries(source)) {
  if (!key || key === '') continue; // skip the root entry
  const name = key.replace(/^node_modules\//, '');
  depMap[name] = {
    version: entry.version,
    integrity: entry.integrity,  // "sha512-<base64>"
    resolved: entry.resolved,    // tarball URL
  };
}

console.log(JSON.stringify(depMap, null, 2));

function flattenDeps(deps, result = {}) {
  if (!deps) return result;
  for (const [name, entry] of Object.entries(deps)) {
    result[`${name}@${entry.version}`] = {
      integrity: entry.integrity,
      resolved: entry.resolved,
    };
    flattenDeps(entry.dependencies, result);
  }
  return result;
}

Expected output: a JSON file with one entry per resolved package, each carrying an integrity field starting with sha512-. Any entry missing integrity or resolved is a security gap — treat it as a pipeline failure.

For pnpm, parse pnpm-lock.yaml with a YAML parser and iterate packages. For Yarn Berry, parse .yarn/cache metadata or use the @yarnpkg/parsers package.

The integrity value you pull out of each entry is not an opaque token. It is an SRI metadata string in exactly the form the browser accepts on a <script> tag: an algorithm label, a hyphen, and the base64 encoding of the raw digest bytes, ending in whatever = padding the digest length requires. A SHA-512 digest is 64 bytes, so its base64 form is always 88 characters with two padding characters; a SHA-384 digest is 48 bytes and encodes to 64 characters with no padding at all. Those fixed lengths are the cheapest possible sanity check in a parser — an integrity field of any other length has been truncated or hand-edited. The encoding rules are the same ones described in Base64 Encoding Rules for SRI Hashes, which is why a lockfile value can be pasted into an HTML attribute and parse cleanly even though it describes entirely different bytes.

Anatomy of an integrity value A lockfile integrity string split into four labelled segments — algorithm prefix, hyphen separator, base64-encoded digest and padding characters — above two boxes contrasting the sha512 value in the lockfile with the sha384 value in served HTML. integrity value recorded in package-lock.json sha512 - p6JcJ7cLBnZUCFXBHUCQaVBSjKmpaVYhcO7yYuo == algorithm separator base64 digest padding same syntax, two scopes, two different sets of bytes sha512- in the lockfile covers the published tarball vs sha384- in the HTML covers the served bundle

Step 2: Validate Registry Origins and Integrity Fields

Permalink to "Step 2: Validate Registry Origins and Integrity Fields"

Resolve the allowed registry origins in .npmrc, then verify that every resolved URL in the lockfile matches an approved prefix and that every integrity value is non-empty and uses an approved algorithm.

# Install lockfile-lint (version-pin for reproducibility)
npm install --save-dev [email protected]
// .lockfile-lintrc.js
module.exports = {
  path: 'package-lock.json',
  type: 'npm',
  validators: {
    'validate-https': true,
    'validate-integrity': true,
    'allowed-hosts': [
      'https://registry.npmjs.org',
      'https://your-private-registry.example.com',
    ],
  },
};
# Run validation — exit code 1 on any violation
npx lockfile-lint --config .lockfile-lintrc.js

For packages sourced from GitHub or git URLs, the integrity field may be absent in older lockfile versions. Enforce validate-integrity to surface these gaps immediately rather than allowing silent pass-through.

Treat allowed-hosts as a security boundary rather than a convenience list. Every host you add is a party that can change the bytes your build consumes, so each addition deserves the same scrutiny as a new production dependency: who operates it, whether it serves over TLS with a certificate you can pin, and what happens to your build if it disappears. The list stays short in practice when installs go through a single upstream, which is the operational argument for Configuring a Private npm Registry Proxy — one approved origin in the lockfile, one cache to audit, and a hard failure the moment a resolved URL points anywhere else.


Step 3: Generate SRI Hashes for Browser-Delivered Assets

Permalink to "Step 3: Generate SRI Hashes for Browser-Delivered Assets"

The lockfile’s SHA-512 values cover downloaded tarballs. Browsers need SHA-384 hashes of the actual files you serve — the minified and bundled output. These are two separate passes; conflating them is the most common lockfile/SRI confusion.

The reason they can never be the same value is mechanical. A tarball contains the package’s published file tree, its package.json, its licence and often its TypeScript declarations and source maps. What reaches the browser is a slice of one entry point, tree-shaken, transpiled to your target syntax, minified with your bundler’s name mangling, and concatenated with dozens of other packages into a single chunk. Not one byte survives the journey unchanged. So the decision of which digest applies is decided entirely by who consumes the artifact: the package manager on the build machine, or the browser over the network.

Which hash applies to which artifact A decision tree branching on what is being hashed: a registry tarball resolves to the SHA-512 value in the lockfile, while a browser-fetched file resolves to a SHA-384 integrity attribute, further split by whether the file is served cross-origin and therefore needs crossorigin anonymous. What are you hashing? tarball served file SHA-512 in the lockfile verified by npm ci / pnpm Fetched from another origin, such as a CDN? yes no CORS required crossorigin anonymous same-origin integrity alone Both passes are required: a valid tarball hash never implies a valid asset hash
# generate-sri-manifest.sh — run after your build step
#!/usr/bin/env bash
set -euo pipefail

DIST_DIR="${1:-dist}"
MANIFEST="sri-manifest.json"

echo '{' > "$MANIFEST"
first=true

for file in "$DIST_DIR"/**/*.js "$DIST_DIR"/**/*.css; do
  [ -f "$file" ] || continue
  hash=$(openssl dgst -sha384 -binary "$file" | openssl base64 -A)
  rel="${file#$DIST_DIR/}"
  if [ "$first" = true ]; then
    first=false
  else
    echo ',' >> "$MANIFEST"
  fi
  printf '  "%s": "sha384-%s"' "$rel" "$hash" >> "$MANIFEST"
done

echo '' >> "$MANIFEST"
echo '}' >> "$MANIFEST"

echo "SRI manifest written to $MANIFEST"
cat "$MANIFEST"

For Static Asset Hash Generation with Webpack, use the webpack-subresource-integrity plugin instead to automate injection directly into the output HTML.

Expected output:

{
  "js/main.a1b2c3.js": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho+wx9nS1+1",
  "css/styles.d4e5f6.css": "sha384-FKCVkp3djZ+K4QKZU4cJvJM6jKzE5Q3FGiS2PECBn3V3Xx9oMcvjxnQb4Qs8ry"
}

Step 4: Inject SRI Attributes into HTML Templates

Permalink to "Step 4: Inject SRI Attributes into HTML Templates"

Use the manifest produced in Step 3 to stamp integrity and crossorigin="anonymous" attributes onto every <script> and <link rel="stylesheet"> tag. The crossorigin="anonymous" attribute is non-optional — without it, browsers perform a no-CORS fetch and ignore the integrity check entirely, silently passing files that should fail.

// scripts/inject-sri.js — post-build HTML stamper
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

const manifest = JSON.parse(readFileSync('sri-manifest.json', 'utf8'));

function stamp(html) {
  // Script tags
  html = html.replace(
    /<script([^>]*)\ssrc="([^"]+)"([^>]*)>/gi,
    (match, pre, src, post) => {
      const key = src.replace(/^\//, '');
      const hash = manifest[key];
      if (!hash) return match;
      // Remove any existing integrity/crossorigin to avoid duplication
      const attrs = `${pre}${post}`.replace(/\s*(integrity|crossorigin)="[^"]*"/gi, '');
      return `<script${attrs} src="${src}" integrity="${hash}" crossorigin="anonymous">`;
    }
  );
  // Link stylesheet tags
  html = html.replace(
    /<link([^>]*)\shref="([^"]+)"([^>]*rel="stylesheet"[^>]*)>/gi,
    (match, pre, href, post) => {
      const key = href.replace(/^\//, '');
      const hash = manifest[key];
      if (!hash) return match;
      const attrs = `${pre}${post}`.replace(/\s*(integrity|crossorigin)="[^"]*"/gi, '');
      return `<link${attrs} href="${href}" rel="stylesheet" integrity="${hash}" crossorigin="anonymous">`;
    }
  );
  return html;
}

for (const file of readdirSync('dist').filter(f => f.endsWith('.html'))) {
  const path = join('dist', file);
  writeFileSync(path, stamp(readFileSync(path, 'utf8')));
  console.log(`Stamped: ${path}`);
}

Step 5: Gate the CI/CD Pipeline

Permalink to "Step 5: Gate the CI/CD Pipeline"

Block merges and deployments on any lockfile drift, integrity mismatch, or empty SRI manifest.

# .github/workflows/lockfile-integrity.yml
name: Lockfile & SRI Integrity

on: [push, pull_request]

jobs:
  verify:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Enforce lockfile consistency
        run: |
          # Fail if package-lock.json is out of sync with package.json
          npm ci --ignore-scripts

      - name: Validate lockfile origins and integrity
        run: npx lockfile-lint@4 --config .lockfile-lintrc.js

      - name: Build
        run: npm run build

      - name: Generate SRI manifest
        run: bash scripts/generate-sri-manifest.sh dist

      - name: Fail on empty SRI manifest
        run: |
          COUNT=$(node -e "const m=require('./sri-manifest.json'); console.log(Object.keys(m).length)")
          if [ "$COUNT" -eq 0 ]; then
            echo "SRI manifest is empty — no assets were hashed. Halting." && exit 1
          fi
          echo "SRI manifest covers $COUNT assets."

      - name: Inject SRI attributes into HTML
        run: node scripts/inject-sri.js

      - name: Upload SRI manifest as artifact
        uses: actions/upload-artifact@v4
        with:
          name: sri-manifest
          path: sri-manifest.json

For GitLab CI, mirror this with a dependency_scan stage:

# .gitlab-ci.yml (relevant stage)
lockfile_integrity:
  stage: verify
  image: node:20-alpine
  script:
    - npm ci --ignore-scripts
    - npx lockfile-lint@4 --config .lockfile-lintrc.js
    - npm run build
    - sh scripts/generate-sri-manifest.sh dist
    - node -e "const m=require('./sri-manifest.json'); if(!Object.keys(m).length) process.exit(1)"
    - node scripts/inject-sri.js
  artifacts:
    paths:
      - sri-manifest.json
  allow_failure: false

Configuration Reference

Permalink to "Configuration Reference"
Option Valid values Security implication
lockfileVersion in package-lock.json 1, 2, 3 Version 3 uses the packages key exclusively; versions 1 and 2 carry a dependencies fallback that can diverge. Pin to v3 with npm config set lockfile-version 3.
integrity algorithm in lockfile sha512-<base64> (npm/pnpm/Yarn Berry), sha1-<hex> (Yarn Classic) SHA-1 is cryptographically broken; migrate Yarn Classic workspaces to Yarn Berry for SHA-512 support.
validate-https (lockfile-lint) true / false Enforces TLS on all download URLs. Never disable in production pipelines.
allowed-hosts (lockfile-lint) Array of URL prefixes Restricts package origins to your approved registries. A missing entry here is the primary vector for dependency confusion.
strict-ssl (.npmrc) true / false Rejects self-signed or expired TLS certificates on registry connections.
package-lock (.npmrc) true / false Prevents npm install from silently updating the lockfile on a developer machine.
SHA algorithm for SRI (browser assets) sha256, sha384, sha512 SHA-384 is the industry standard for SRI. SHA-256 has an adequate security margin but SHA-384 is preferred. SHA-512 increases payload size without a practical security gain over SHA-384.

Integration with Adjacent Tooling

Permalink to "Integration with Adjacent Tooling"

The lockfile validation step sits between dependency installation and artifact packaging in the pipeline. Its outputs feed directly into the next two stages:

Lockfile → SBOM: Feed the resolved dependency map (Step 1 output) into Automated SBOM Generation tooling such as Syft or @cyclonedx/cyclonedx-npm. The lockfile’s pinned versions ensure that the SBOM reflects what was actually installed, not what the manifest ranges permit.

Lockfile → Provenance: The integrity hashes from the lockfile, alongside the SRI manifest produced in Step 3, constitute the primary evidence for Provenance Verification Workflows. Attach both artifacts to your build attestation (for example, using slsa-github-generator) so consumers can verify the full chain from source commit to deployed asset.

SRI manifest → CSP header: The file paths and hashes in sri-manifest.json can drive automatic Content-Security-Policy header generation. A script-src directive listing the SHA-384 hashes from the manifest — combined with Configuring Content-Security-Policy with SRI — gives you defence in depth: the server rejects unknown scripts at the HTTP layer and the browser rejects modified scripts at fetch time.


Troubleshooting

Permalink to "Troubleshooting"
Error / symptom Root cause Fix
lockfile-lint: Integrity validation failed for [email protected] The integrity field in the lockfile does not match the SHA-512 of the currently published tarball. Possible registry compromise or local cache corruption. Delete node_modules and ~/.npm, run npm ci, re-run lockfile-lint. If the mismatch persists, report to the package maintainer and the npm security team.
npm ERR! Invalid: lock file's <pkg> does not satisfy <range> package.json was updated without regenerating package-lock.json. Run npm install locally to update the lockfile, commit both files, then re-run npm ci in CI.
SRI manifest is empty The glob pattern in generate-sri-manifest.sh found no matching files because the build output directory name or file extensions differ from the script’s expectations. Check that DIST_DIR matches your actual output directory and that the glob covers .js and .css extensions used by your bundler.
Refused to execute script ... because its hash, integrity, or CSP does not match (browser console) inject-sri.js ran before the build completed, hashing stale files, or the build content-hashes changed after SRI injection. Always run SRI generation and injection as a single post-build atomic step. If your bundler adds a content hash to filenames, regenerate the SRI manifest immediately after each build.
lockfile-lint: URL not in allowed-hosts A transitive dependency resolves from a registry not listed in allowed-hosts. Common after adding a package that pulls from GitHub Packages or a CDN. Audit the new URL, confirm it is legitimate, add it to allowed-hosts, commit the updated config, and re-run.
npm warn lockfileVersion during npm ci CI is running a different npm major version than the developer who committed the lockfile. Pin the npm version in your CI action (npm: '10.x') and add an .nvmrc or engines.npm field to package.json.

Security Boundary Note

Permalink to "Security Boundary Note"

Lockfile mapping verifies that installed packages match the hashes recorded at the time the lockfile was last committed. It does not protect against:

  • Malicious code present when the lockfile was created. If a developer ran npm install immediately after a malicious version was published and committed the resulting lockfile, that malicious hash is now the trusted value. Pair lockfile validation with Vulnerability Tracking & Triage to catch CVEs in pinned packages.
  • Compromised build tooling. Lockfile integrity says nothing about the compiler, bundler, or test runner used during the build. Use hermetic build environments and verify tool signatures separately.
  • Runtime injection after deployment. SRI attributes protect browser-delivered assets at fetch time, but they cannot stop server-side injection or DNS hijacking that redirects the asset URL itself. Complement SRI with a strict script policy — Layering CSP Nonces, SRI and Trusted Types shows how the three controls compose without cancelling each other out.
  • Packages installed with --ignore-scripts bypassed. npm ci --ignore-scripts prevents postinstall scripts from executing but does not affect the installed files or their hashes. The flag is easy to lose when a developer runs a plain install locally, so pin the behaviour in .npmrc as described in Disabling npm Install Scripts rather than relying on the CI invocation alone.
  • A lockfile edited by hand. Nothing in npm ci distinguishes a lockfile the package manager wrote from one a contributor edited in a text editor. The hashes it checks are the hashes it was given, so a diff that swaps a single resolved host and its matching integrity value installs cleanly and passes every local check. Detecting that requires reviewing the lockfile diff itself, not the install output.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
What is the difference between package.json and package-lock.json for security?

package.json contains semver ranges that can resolve to many different versions over time. package-lock.json records the exact resolved version, the tarball URL, and a SHA-512 integrity hash for every package in the tree, transitive ones included. Security controls must operate on the lockfile, because the manifest only describes intent while the lockfile describes what was actually installed on the machine that produced the build.

Does npm ci verify lockfile integrity automatically?

npm ci installs the exact locked versions and does check each downloaded tarball against the integrity value recorded in the lockfile, so a corrupted or altered download fails on the spot. What it cannot tell you is whether that recorded value is itself trustworthy: if the lockfile was committed with the hash of a malicious release, npm ci reproduces it faithfully. It also does not restrict which hosts the resolved URLs point to. Audit both with lockfile-lint.

How do pnpm and Yarn lockfile formats differ from npm?

npm lockfile version 3 stores SHA-512 digests in an integrity field on every entry under the packages key, and pnpm records the same sha512- form in pnpm-lock.yaml. Yarn Classic writes an integrity line plus a legacy SHA-1 fragment appended to the resolved URL. Yarn Berry replaces both with a cache-key-prefixed SHA-512 checksum in structured YAML. Each format needs its own parser, but all of them bind a package name and version to specific bytes.

Can lockfile validation prevent dependency confusion attacks?

Yes, when you enforce allowed registry origins alongside integrity checks. A dependency confusion attack publishes a public package under the same name as one of your private packages, hoping the resolver prefers the public copy. Validating that every resolved URL belongs to an approved host set stops the substitution before installation, because the attacker tarball resolves from a registry you never approved. Scope prefixes and a registry proxy close the same gap one layer earlier.

How do I regenerate SRI hashes after a lockfile update?

Run SRI generation in the same CI job that runs npm ci, immediately after the build step, so the manifest can never describe stale files. Keep the tarball integrity values from the lockfile for the audit trail, then compute SHA-384 digests over the final browser-delivered bundles, which are different bytes entirely. Commit or attach the resulting sri-manifest.json next to the lockfile so a reviewer sees both change together.

Which lockfile changes should a pull request review treat as suspicious?

Three signatures matter most. An integrity value that changes while the version string stays the same. A resolved URL whose host differs from every other entry in the file. And a new transitive package introduced by a diff that touched no manifest range at all. Any of the three means the tree was edited by something other than the package manager, and the branch should be regenerated from the manifest before it merges.


Permalink to "Related"

Articles in This Topic

Parsing package-lock.json for Dependency Audits
Detecting Lockfile Tampering in Pull Requests
Back to Supply Chain Auditing & Dependency Verification