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.
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 installimmediately 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-scriptsbypassed.npm ci --ignore-scriptsprevents postinstall scripts from executing but does not affect the installed files or their hashes.
Related
Permalink to "Related"- Parsing package-lock.json for Dependency Audits — deep-dive into AST-level parsing, resolving the v1/v2/v3 format differences, and cross-referencing resolved nodes with CVE databases
- Dependency Pinning Best Practices — version pinning strategies for direct and transitive dependencies, including monorepo workspace roots
- Automated SBOM Generation — converting the verified dependency map into a CycloneDX or SPDX manifest for compliance reporting
- Provenance Verification Workflows — attaching SLSA attestations and Sigstore signatures to build artifacts downstream of lockfile validation