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. 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 pipeline halt + alert mismatch

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.


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.


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.

# 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 Configuring Content-Security-Policy with SRI policy.
  • 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.

Permalink to "Related"

Articles in This Topic

Parsing package-lock.json for Dependency Audits
Back to Supply Chain Auditing & Dependency Verification