snyk-nodejs-lockfile-parser
Version:
Generate a dep tree given a lockfile
148 lines • 6.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.hashLabelsFromIntegrity = hashLabelsFromIntegrity;
exports.distributionUrlLabel = distributionUrlLabel;
exports.hashLabelFromResolvedFragment = hashLabelFromResolvedFragment;
exports.getComponentMetadataLabels = getComponentMetadataLabels;
const debugModule = require("debug");
const debug = debugModule('snyk-nodejs-lockfile-parser:component-metadata');
// Maps a Subresource Integrity (SRI) algorithm token to its dep-graph label key.
// Labels use the `hash:<algorithm>` convention with hyphenated algorithm names
// (`hash:sha-512`, `hash:sha-256`, ...) and lowercase-hex values. Hex matches the CycloneDX
// hash `content` format, so downstream SBOM generation can populate CycloneDX component
// hashes / SPDX package checksums directly. SRI base64 values are decoded to lowercase hex
// below.
const SRI_ALG_TO_LABEL = {
md5: 'hash:md5',
sha1: 'hash:sha-1',
sha256: 'hash:sha-256',
sha384: 'hash:sha-384',
sha512: 'hash:sha-512',
};
// Expected raw digest length (bytes) per algorithm. `Buffer.from(x, 'base64')` is lenient and
// will not throw on a corrupt/truncated value, so we reject anything whose decoded length does
// not match — otherwise we would emit a hex string that violates the CycloneDX `content` regex.
const SRI_ALG_BYTES = {
md5: 16,
sha1: 20,
sha256: 32,
sha384: 48,
sha512: 64,
};
/**
* Convert a lockfile `integrity` (SRI) string into `hash:<algorithm>` labels with
* lowercase-hex values. Returns an empty object (and debug-logs) when the value is absent
* or unparseable — never throws.
*/
function hashLabelsFromIntegrity(integrity, nodeId) {
if (!integrity) {
debug(`No integrity for ${nodeId}; skipping hash labels`);
return {};
}
const labels = {};
// SRI may contain multiple whitespace-separated hashes, each "<alg>-<base64>[?opts]".
for (const token of integrity.trim().split(/\s+/)) {
const dashIdx = token.indexOf('-');
if (dashIdx === -1) {
debug(`Unrecognised integrity "${token}" for ${nodeId}; skipping`);
continue;
}
const alg = token.slice(0, dashIdx).toLowerCase();
const base64 = token.slice(dashIdx + 1).split('?')[0]; // strip optional ?opts
const key = SRI_ALG_TO_LABEL[alg];
if (!key || !base64) {
debug(`Unrecognised integrity "${token}" for ${nodeId}; skipping`);
continue;
}
const digest = Buffer.from(base64, 'base64');
if (digest.length !== SRI_ALG_BYTES[alg]) {
debug(`Integrity "${token}" for ${nodeId} decoded to ${digest.length} bytes, ` +
`expected ${SRI_ALG_BYTES[alg]} for ${alg}; skipping`);
continue;
}
labels[key] = digest.toString('hex');
}
return labels;
}
/**
* Produce a `distribution:url` label from a lockfile `resolved` value when it is an
* http(s) tarball URL. `resolved` can also be a `file:`/workspace path for local deps;
* those are debug-logged and skipped.
*/
function distributionUrlLabel(resolved, nodeId) {
if (!resolved) {
debug(`No resolved URL for ${nodeId}; skipping distribution:url label`);
return {};
}
let url;
try {
url = new URL(resolved);
}
catch {
debug(`resolved "${resolved}" for ${nodeId} is not a parseable URL; skipping distribution:url label`);
return {};
}
// URL normalises the scheme to lowercase, so this also accepts HTTPS://, Http://, etc.
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
debug(`resolved "${resolved}" for ${nodeId} is not an http(s) URL; skipping distribution:url label`);
return {};
}
// Strip any embedded basic-auth userinfo (scheme://user:pass@host) so private-registry
// credentials never leak into the emitted label or downstream SBOM externalReferences.
if (url.username || url.password) {
url.password = '';
url.username = '';
debug(`Stripped credentials from resolved URL for ${nodeId}`);
}
// yarn v1 appends the tarball shasum as a URL fragment (`…-1.0.0.tgz#<sha1>`); npm does not.
// Drop it so distribution:url is a clean tarball URL — the shasum is surfaced separately as a
// hash:sha-1 label (see hashLabelFromResolvedFragment).
if (url.hash) {
url.hash = '';
}
return { 'distribution:url': url.toString() };
}
/**
* yarn v1 `resolved` values embed the npm-registry tarball shasum as a URL fragment
* (`…-1.0.0.tgz#<40-hex-sha1>`). For older lockfiles that carry no `integrity` line this is the
* only available hash, so surface it as `hash:sha-1`. Restricted to http(s) tarball URLs: a
* `#<40-hex>` fragment on a `git:`/`git+ssh:` resolution is a commit SHA, not a package hash.
*/
function hashLabelFromResolvedFragment(resolved, nodeId) {
if (!resolved) {
return {};
}
let url;
try {
url = new URL(resolved);
}
catch {
return {};
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
// git/file/etc. — a fragment here is a commit-ish, not a package shasum.
return {};
}
const fragment = url.hash.replace(/^#/, '');
if (!/^[0-9a-f]{40}$/i.test(fragment)) {
if (fragment) {
debug(`resolved fragment "${fragment}" for ${nodeId} is not a sha1 shasum; skipping`);
}
return {};
}
return { 'hash:sha-1': fragment.toLowerCase() };
}
/**
* Build the full set of component-metadata labels (package hashes + distribution URL) for a
* node, sourced from its lockfile `integrity` / `resolved` values.
*/
function getComponentMetadataLabels(node) {
return {
...distributionUrlLabel(node.resolved, node.id),
// Fragment-derived sha1 first so an explicit `integrity` sha1 wins on overlap (they describe
// the same digest for npm-registry tarballs).
...hashLabelFromResolvedFragment(node.resolved, node.id),
...hashLabelsFromIntegrity(node.integrity, node.id),
};
}
//# sourceMappingURL=component-metadata-labels.js.map