Skip to content

Commit c53242f

Browse files
authored
Merge pull request #312 from snyk/feat/CMPA-603-npm-component-metadata
feat: [CMPA-603] Report npm package hashes and distribution URLs as node labels Emit per-package component-metadata labels on npm lockfile v1 DepTree and v2/v3 DepGraph nodes, sourced from the lockfile integrity (SRI) and resolved fields: - `hash:<algorithm>` - one label per hash, keyed with hyphenated algorithm names (`hash:md5`, `hash:sha-1`, `hash:sha-256`, `hash:sha-384`, `hash:sha-512`). The value is the lowercase-hex digest decoded from the SRI base64. Hex matches the CycloneDX hash content format, so downstream SBOM generation can populate CycloneDX component hashes / SPDX package checksums directly. - `distribution:url` - the package tarball URL taken from resolved (http(s) URLs only; file:/link paths are skipped).
2 parents c1921d0 + cd13deb commit c53242f

16 files changed

Lines changed: 543 additions & 1 deletion

File tree

lib/component-metadata-labels.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import * as debugModule from 'debug';
2+
3+
const debug = debugModule('snyk-nodejs-lockfile-parser:component-metadata');
4+
5+
// Maps a Subresource Integrity (SRI) algorithm token to its dep-graph label key.
6+
// Labels use the `hash:<algorithm>` convention with hyphenated algorithm names
7+
// (`hash:sha-512`, `hash:sha-256`, ...) and lowercase-hex values. Hex matches the CycloneDX
8+
// hash `content` format, so downstream SBOM generation can populate CycloneDX component
9+
// hashes / SPDX package checksums directly. SRI base64 values are decoded to lowercase hex
10+
// below.
11+
const SRI_ALG_TO_LABEL: Record<string, string> = {
12+
md5: 'hash:md5',
13+
sha1: 'hash:sha-1',
14+
sha256: 'hash:sha-256',
15+
sha384: 'hash:sha-384',
16+
sha512: 'hash:sha-512',
17+
};
18+
19+
// Expected raw digest length (bytes) per algorithm. `Buffer.from(x, 'base64')` is lenient and
20+
// will not throw on a corrupt/truncated value, so we reject anything whose decoded length does
21+
// not match — otherwise we would emit a hex string that violates the CycloneDX `content` regex.
22+
const SRI_ALG_BYTES: Record<string, number> = {
23+
md5: 16,
24+
sha1: 20,
25+
sha256: 32,
26+
sha384: 48,
27+
sha512: 64,
28+
};
29+
30+
/**
31+
* Convert a lockfile `integrity` (SRI) string into `hash:<algorithm>` labels with
32+
* lowercase-hex values. Returns an empty object (and debug-logs) when the value is absent
33+
* or unparseable — never throws.
34+
*/
35+
export function hashLabelsFromIntegrity(
36+
integrity: string | undefined,
37+
nodeId: string,
38+
): Record<string, string> {
39+
if (!integrity) {
40+
debug(`No integrity for ${nodeId}; skipping hash labels`);
41+
return {};
42+
}
43+
44+
const labels: Record<string, string> = {};
45+
// SRI may contain multiple whitespace-separated hashes, each "<alg>-<base64>[?opts]".
46+
for (const token of integrity.trim().split(/\s+/)) {
47+
const dashIdx = token.indexOf('-');
48+
if (dashIdx === -1) {
49+
debug(`Unrecognised integrity "${token}" for ${nodeId}; skipping`);
50+
continue;
51+
}
52+
const alg = token.slice(0, dashIdx).toLowerCase();
53+
const base64 = token.slice(dashIdx + 1).split('?')[0]; // strip optional ?opts
54+
const key = SRI_ALG_TO_LABEL[alg];
55+
if (!key || !base64) {
56+
debug(`Unrecognised integrity "${token}" for ${nodeId}; skipping`);
57+
continue;
58+
}
59+
const digest = Buffer.from(base64, 'base64');
60+
if (digest.length !== SRI_ALG_BYTES[alg]) {
61+
debug(
62+
`Integrity "${token}" for ${nodeId} decoded to ${digest.length} bytes, ` +
63+
`expected ${SRI_ALG_BYTES[alg]} for ${alg}; skipping`,
64+
);
65+
continue;
66+
}
67+
labels[key] = digest.toString('hex');
68+
}
69+
70+
return labels;
71+
}
72+
73+
/**
74+
* Produce a `distribution:url` label from a lockfile `resolved` value when it is an
75+
* http(s) tarball URL. `resolved` can also be a `file:`/workspace path for local deps;
76+
* those are debug-logged and skipped.
77+
*/
78+
export function distributionUrlLabel(
79+
resolved: string | undefined,
80+
nodeId: string,
81+
): Record<string, string> {
82+
if (!resolved) {
83+
debug(`No resolved URL for ${nodeId}; skipping distribution:url label`);
84+
return {};
85+
}
86+
87+
let url: URL;
88+
try {
89+
url = new URL(resolved);
90+
} catch {
91+
debug(
92+
`resolved "${resolved}" for ${nodeId} is not a parseable URL; skipping distribution:url label`,
93+
);
94+
return {};
95+
}
96+
97+
// URL normalises the scheme to lowercase, so this also accepts HTTPS://, Http://, etc.
98+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
99+
debug(
100+
`resolved "${resolved}" for ${nodeId} is not an http(s) URL; skipping distribution:url label`,
101+
);
102+
return {};
103+
}
104+
105+
// Strip any embedded basic-auth userinfo (scheme://user:pass@host) so private-registry
106+
// credentials never leak into the emitted label or downstream SBOM externalReferences.
107+
if (url.username || url.password) {
108+
url.password = '';
109+
url.username = '';
110+
debug(`Stripped credentials from resolved URL for ${nodeId}`);
111+
}
112+
113+
return { 'distribution:url': url.toString() };
114+
}
115+
116+
/**
117+
* Build the full set of component-metadata labels (package hashes + distribution URL) for a
118+
* node, sourced from its lockfile `integrity` / `resolved` values.
119+
*/
120+
export function getComponentMetadataLabels(node: {
121+
id: string;
122+
integrity?: string;
123+
resolved?: string;
124+
}): Record<string, string> {
125+
return {
126+
...hashLabelsFromIntegrity(node.integrity, node.id),
127+
...distributionUrlLabel(node.resolved, node.id),
128+
};
129+
}

lib/dep-graph-builders/npm-lock-v2/extract-npm-lock-v2-pkgs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type NpmLockPkg = {
1010
dev?: boolean;
1111
optional?: boolean;
1212
resolved?: string;
13+
integrity?: string;
1314
license?: string;
1415
engines?: Record<string, string>;
1516
inBundle?: boolean;

lib/dep-graph-builders/npm-lock-v2/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const parseNpmLockV2Project = async (
4040
includeOptionalDeps,
4141
pruneNpmStrictOutOfSync,
4242
showNpmScope,
43+
includeComponentMetadata,
4344
} = options;
4445

4546
const pkgJson: PackageJsonBase = parsePkgJson(
@@ -55,6 +56,7 @@ export const parseNpmLockV2Project = async (
5556
strictOutOfSync,
5657
pruneNpmStrictOutOfSync,
5758
showNpmScope,
59+
includeComponentMetadata,
5860
});
5961

6062
return depgraph;
@@ -71,6 +73,7 @@ export const buildDepGraphNpmLockV2 = async (
7173
includeOptionalDeps,
7274
pruneNpmStrictOutOfSync,
7375
showNpmScope,
76+
includeComponentMetadata,
7477
} = options;
7578
const depGraphBuilder = new DepGraphBuilder(
7679
{ name: 'npm' },
@@ -135,6 +138,7 @@ export const buildDepGraphNpmLockV2 = async (
135138
pkgJson.overrides,
136139
pruneNpmStrictOutOfSync,
137140
showNpmScope,
141+
includeComponentMetadata,
138142
);
139143
return depGraphBuilder.build();
140144
};
@@ -160,6 +164,7 @@ const dfsVisit = async (
160164
overrides: Overrides | undefined,
161165
pruneNpmStrictOutOfSync?: boolean,
162166
showNpmScope?: boolean,
167+
includeComponentMetadata?: boolean,
163168
): Promise<void> => {
164169
visitedMap.add(node.id);
165170

@@ -191,7 +196,10 @@ const dfsVisit = async (
191196
);
192197

193198
if (!visitedMap.has(childNode.id)) {
194-
addPkgNodeToGraph(depGraphBuilder, childNode, { showNpmScope });
199+
addPkgNodeToGraph(depGraphBuilder, childNode, {
200+
showNpmScope,
201+
includeComponentMetadata,
202+
});
195203
await dfsVisit(
196204
depGraphBuilder,
197205
childNode,
@@ -214,6 +222,7 @@ const dfsVisit = async (
214222
overrides,
215223
undefined,
216224
showNpmScope,
225+
includeComponentMetadata,
217226
);
218227
}
219228

@@ -405,6 +414,8 @@ const getChildNode = (
405414
isDev: depInfo.isDev,
406415
inBundle: depData.inBundle,
407416
key: childNodeKey,
417+
integrity: depData.integrity,
418+
resolved: depData.resolved,
408419
...(aliasInfo ? { alias: { ...aliasInfo, version: depData.version } } : {}),
409420
};
410421
};

lib/dep-graph-builders/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type DepGraphBuildOptions = {
4747
pruneNpmStrictOutOfSync?: boolean;
4848
honorAliases?: boolean;
4949
showNpmScope?: boolean;
50+
includeComponentMetadata?: boolean;
5051
};
5152

5253
export type LockFileParseOptions = {

lib/dep-graph-builders/util.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { OutOfSyncError } from '../errors';
66
import { parseJsonFile } from '../utils';
77
import { LockfileType } from '../parsers';
88
import { parseNpmAlias } from '../aliasesPreprocessors/pkgJson';
9+
import { getComponentMetadataLabels } from '../component-metadata-labels';
910

1011
export type Dependencies = Record<
1112
string,
@@ -21,6 +22,9 @@ export interface PkgNode {
2122
missingLockFileEntry?: boolean;
2223
inBundle?: boolean;
2324
key?: string;
25+
// Raw lockfile metadata, emitted as component-metadata labels when requested.
26+
integrity?: string;
27+
resolved?: string;
2428
alias?: {
2529
aliasName: string;
2630
aliasTargetDepName: string;
@@ -50,6 +54,7 @@ export const addPkgNodeToGraph = (
5054
isCyclic?: boolean;
5155
isWorkspacePkg?: boolean;
5256
showNpmScope?: boolean;
57+
includeComponentMetadata?: boolean;
5358
},
5459
): DepGraphBuilder => {
5560
return depGraphBuilder.addPkgNode(
@@ -67,6 +72,8 @@ export const addPkgNodeToGraph = (
6772
...(node.alias && {
6873
alias: `${node.alias.aliasName}=>${node.alias.aliasTargetDepName}@${node.version}`,
6974
}),
75+
...(options.includeComponentMetadata &&
76+
getComponentMetadataLabels(node)),
7077
},
7178
},
7279
);

lib/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ async function buildDepTree(
112112
strictOutOfSync: boolean = true,
113113
defaultManifestFileName: string = 'package.json',
114114
showNpmScope?: boolean,
115+
includeComponentMetadata?: boolean,
115116
): Promise<PkgTree> {
116117
if (!lockfileType) {
117118
lockfileType = LockfileType.npm;
@@ -153,6 +154,7 @@ async function buildDepTree(
153154
includeDev,
154155
strictOutOfSync,
155156
showNpmScope,
157+
includeComponentMetadata,
156158
);
157159
}
158160

@@ -164,6 +166,7 @@ async function buildDepTreeFromFiles(
164166
strictOutOfSync = true,
165167
honorAliases?: boolean,
166168
showNpmScope?: boolean,
169+
includeComponentMetadata?: boolean,
167170
): Promise<PkgTree> {
168171
if (!root || !manifestFilePath || !lockFilePath) {
169172
throw new Error('Missing required parameters for buildDepTreeFromFiles()');
@@ -209,6 +212,7 @@ async function buildDepTreeFromFiles(
209212
strictOutOfSync,
210213
manifestFilePath,
211214
showNpmScope,
215+
includeComponentMetadata,
212216
);
213217
}
214218

lib/parsers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export interface LockfileParser {
110110
includeDev?: boolean,
111111
strictOutOfSync?: boolean,
112112
showNpmScope?: boolean,
113+
includeComponentMetadata?: boolean,
113114
) => Promise<PkgTree>;
114115
}
115116

lib/parsers/lock-parser-base.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export abstract class LockParserBase implements LockfileParser {
7171
includeDev = false,
7272
strictOutOfSync = true,
7373
showNpmScope?: boolean,
74+
includeComponentMetadata?: boolean,
7475
): Promise<PkgTree> {
7576
if (lockfile.type !== this.type) {
7677
throw new InvalidUserInputError(
@@ -115,6 +116,7 @@ export abstract class LockParserBase implements LockfileParser {
115116
yarnLock,
116117
manifestFile.resolutions,
117118
showNpmScope,
119+
includeComponentMetadata,
118120
);
119121

120122
// all paths are identified, we can create a graph representing what depends on what
@@ -493,6 +495,7 @@ export abstract class LockParserBase implements LockfileParser {
493495
lockfile: Lockfile, // eslint-disable-line @typescript-eslint/no-unused-vars
494496
resolutions?: ManifestDependencies, // eslint-disable-line @typescript-eslint/no-unused-vars
495497
showNpmScope?: boolean, // eslint-disable-line @typescript-eslint/no-unused-vars
498+
includeComponentMetadata?: boolean, // eslint-disable-line @typescript-eslint/no-unused-vars
496499
): DepMap {
497500
throw new Error('Not implemented');
498501
}

lib/parsers/package-lock-parser.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { DepMap, DepMapItem, LockParserBase } from './lock-parser-base';
1111
import { config } from '../config';
1212
import { parseJsonFile } from '../utils';
13+
import { getComponentMetadataLabels } from '../component-metadata-labels';
1314

1415
export interface PackageLock {
1516
name: string;
@@ -30,6 +31,8 @@ export interface PackageLockDep {
3031
};
3132
dependencies?: PackageLockDeps;
3233
dev?: boolean;
34+
integrity?: string;
35+
resolved?: string;
3336
}
3437

3538
export class PackageLockParser extends LockParserBase {
@@ -54,13 +57,15 @@ export class PackageLockParser extends LockParserBase {
5457
includeDev: boolean = false,
5558
strictOutOfSync: boolean = true,
5659
showNpmScope?: boolean,
60+
includeComponentMetadata?: boolean,
5761
): Promise<PkgTree> {
5862
const dependencyTree = await super.getDependencyTree(
5963
manifestFile,
6064
lockfile,
6165
includeDev,
6266
strictOutOfSync,
6367
showNpmScope,
68+
includeComponentMetadata,
6469
);
6570
const meta = {
6671
lockfileVersion: (lockfile as PackageLock).lockfileVersion,
@@ -77,6 +82,7 @@ export class PackageLockParser extends LockParserBase {
7782
lockfile: Lockfile,
7883
resolutions?: ManifestDependencies,
7984
showNpmScope?: boolean,
85+
includeComponentMetadata?: boolean,
8086
): DepMap {
8187
const packageLock = lockfile as PackageLock;
8288
const depMap: DepMap = {};
@@ -92,6 +98,12 @@ export class PackageLockParser extends LockParserBase {
9298
...(showNpmScope && {
9399
'npm:scope': dep.dev ? Scope.dev : Scope.prod,
94100
}),
101+
...(includeComponentMetadata &&
102+
getComponentMetadataLabels({
103+
id: `${depName}@${dep.version}`,
104+
integrity: dep.integrity,
105+
resolved: dep.resolved,
106+
})),
95107
},
96108
name: depName,
97109
requires: [],

lib/parsers/yarn-lock-parser.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as yarnLockfileParser from '@yarnpkg/lockfile';
2+
import * as debugModule from 'debug';
23

34
import {
45
Dep,
@@ -13,6 +14,8 @@ import { InvalidUserInputError } from '../errors';
1314
import { DepMap, LockParserBase } from './lock-parser-base';
1415
import { config } from '../config';
1516

17+
const debug = debugModule('snyk-nodejs-lockfile-parser:component-metadata');
18+
1619
export type YarnLockFileTypes = LockfileType.yarn | LockfileType.yarn2;
1720

1821
export interface YarnLock {
@@ -60,7 +63,15 @@ export class YarnLockParser extends LockParserBase {
6063
includeDev = false,
6164
strictOutOfSync = true,
6265
showNpmScope?: boolean,
66+
includeComponentMetadata?: boolean,
6367
): Promise<PkgTree> {
68+
if (includeComponentMetadata) {
69+
debug(
70+
'includeComponentMetadata is not yet supported for yarn lockfiles; ' +
71+
'no hash:* / distribution:url labels will be emitted',
72+
);
73+
}
74+
6475
const depTree = await super.getDependencyTree(
6576
manifestFile,
6677
lockfile,

0 commit comments

Comments
 (0)