cra-audit 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,146 +1,330 @@
1
- 'use strict';
2
-
3
- const crypto = require('node:crypto');
4
- const { parseLockfile } = require('./lockfile-parser');
5
-
6
- const TOOL_NAME = 'cra-audit';
7
- const TOOL_VERSION = require('../../package.json').version;
8
-
9
- /**
10
- * Generates a Software Bill of Materials from the project lockfile.
11
- * Supported formats: CycloneDX 1.5 (JSON) and SPDX 2.3 (JSON).
12
- * Required by CRA Annex I and BSI TR-03183 Part 2.
13
- */
14
- function generateSbom(projectRoot, { format = 'cyclonedx' } = {}) {
15
- const parsed = parseLockfile(projectRoot);
16
- if (!parsed.ok) {
17
- return { ok: false, error: parsed.error };
18
- }
19
-
20
- const normalizedFormat = String(format).toLowerCase();
21
- let document;
22
- if (normalizedFormat === 'spdx') {
23
- document = buildSpdx(parsed);
24
- } else if (normalizedFormat === 'cyclonedx' || normalizedFormat === 'cdx') {
25
- document = buildCycloneDx(parsed);
26
- } else {
27
- return { ok: false, error: `Unsupported SBOM format: "${format}". Use "cyclonedx" or "spdx".` };
28
- }
29
-
30
- return {
31
- ok: true,
32
- format: normalizedFormat === 'spdx' ? 'spdx' : 'cyclonedx',
33
- componentCount: parsed.components.length,
34
- document,
35
- };
36
- }
37
-
38
- function buildCycloneDx(parsed) {
39
- const timestamp = new Date().toISOString();
40
- const serialNumber = `urn:uuid:${crypto.randomUUID()}`;
41
-
42
- const components = parsed.components.map((c) => {
43
- const component = {
44
- type: 'library',
45
- 'bom-ref': c.purl || `${c.name}@${c.version}`,
46
- name: c.name,
47
- version: c.version || undefined,
48
- scope: c.scope,
49
- purl: c.purl || undefined,
50
- };
51
- if (c.hashAlgorithm && c.hashValue) {
52
- component.hashes = [{ alg: c.hashAlgorithm, content: c.hashValue }];
53
- }
54
- if (c.license) {
55
- component.licenses = [normalizeCdxLicense(c.license)];
56
- }
57
- return component;
58
- });
59
-
60
- return {
61
- bomFormat: 'CycloneDX',
62
- specVersion: '1.5',
63
- serialNumber,
64
- version: 1,
65
- metadata: {
66
- timestamp,
67
- tools: [{ vendor: 'cra-audit', name: TOOL_NAME, version: TOOL_VERSION }],
68
- component: {
69
- type: 'application',
70
- 'bom-ref': `pkg:npm/${parsed.root.name}@${parsed.root.version}`,
71
- name: parsed.root.name,
72
- version: parsed.root.version,
73
- },
74
- },
75
- components,
76
- };
77
- }
78
-
79
- function normalizeCdxLicense(license) {
80
- if (typeof license === 'string') {
81
- // Use SPDX expression when it contains operators, else a license id.
82
- if (/\s(AND|OR|WITH)\s/i.test(license)) {
83
- return { expression: license };
84
- }
85
- return { license: { id: license } };
86
- }
87
- return { license: { name: String(license) } };
88
- }
89
-
90
- function buildSpdx(parsed) {
91
- const created = new Date().toISOString();
92
- const docNamespace = `https://spdx.org/spdxdocs/${parsed.root.name}-${crypto.randomUUID()}`;
93
-
94
- const packages = parsed.components.map((c, index) => {
95
- const spdxId = `SPDXRef-Package-${index}`;
96
- const pkg = {
97
- name: c.name,
98
- SPDXID: spdxId,
99
- versionInfo: c.version || 'NOASSERTION',
100
- downloadLocation: c.resolved || 'NOASSERTION',
101
- filesAnalyzed: false,
102
- licenseConcluded: c.license || 'NOASSERTION',
103
- licenseDeclared: c.license || 'NOASSERTION',
104
- copyrightText: 'NOASSERTION',
105
- externalRefs: c.purl
106
- ? [{
107
- referenceCategory: 'PACKAGE-MANAGER',
108
- referenceType: 'purl',
109
- referenceLocator: c.purl,
110
- }]
111
- : undefined,
112
- };
113
- if (c.hashAlgorithm && c.hashValue) {
114
- pkg.checksums = [{ algorithm: c.hashAlgorithm.replace('-', ''), checksumValue: c.hashValue }];
115
- }
116
- return pkg;
117
- });
118
-
119
- const rootSpdxId = 'SPDXRef-Package-root';
120
- packages.unshift({
121
- name: parsed.root.name,
122
- SPDXID: rootSpdxId,
123
- versionInfo: parsed.root.version,
124
- downloadLocation: 'NOASSERTION',
125
- filesAnalyzed: false,
126
- licenseConcluded: 'NOASSERTION',
127
- licenseDeclared: 'NOASSERTION',
128
- copyrightText: 'NOASSERTION',
129
- });
130
-
131
- return {
132
- spdxVersion: 'SPDX-2.3',
133
- dataLicense: 'CC0-1.0',
134
- SPDXID: 'SPDXRef-DOCUMENT',
135
- name: `${parsed.root.name}@${parsed.root.version}`,
136
- documentNamespace: docNamespace,
137
- creationInfo: {
138
- created,
139
- creators: [`Tool: ${TOOL_NAME}-${TOOL_VERSION}`],
140
- },
141
- documentDescribes: [rootSpdxId],
142
- packages,
143
- };
144
- }
145
-
146
- module.exports = { generateSbom };
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const path = require('node:path');
5
+ const { readJson } = require('../utils/fs');
6
+ const { parseLockfile, buildPurl, componentKey } = require('./lockfile-parser');
7
+ const {
8
+ readInstalledManifest, licenseFromManifest, creatorFromManifest, repositoryUrl,
9
+ } = require('./installed-metadata');
10
+
11
+ const TOOL_NAME = 'cra-audit';
12
+ const TOOL_VERSION = require('../../package.json').version;
13
+ const TOOL_URL = 'https://github.com/migohe14/cra-audit';
14
+ const NPM_REGISTRY = 'https://registry.npmjs.org';
15
+
16
+ /**
17
+ * Generates a Software Bill of Materials from the project lockfile.
18
+ * Supported formats: CycloneDX 1.6 (JSON) and SPDX 2.3 (JSON).
19
+ * Required by CRA Annex I and BSI TR-03183 Part 2.
20
+ *
21
+ * The CycloneDX output follows the field mapping of TR-03183-2 v2.1.0
22
+ * (appendix 8.5): SBOM creator in `metadata.manufacturer`, component creator
23
+ * in `manufacturer`, SHA-512 of the deployable tarball on the `distribution`
24
+ * reference, the `bsi:component:*` properties, a full `dependencies` graph and
25
+ * its completeness in `compositions`.
26
+ *
27
+ * Fields a lockfile does not carry (licenses for Yarn/pnpm, component
28
+ * creators) are read from the installed packages in node_modules.
29
+ *
30
+ * @param {string} projectRoot
31
+ * @param {{ format?: string, creator?: string }} [options]
32
+ * `creator`: email or URL of the SBOM creator. Defaults to the project's
33
+ * package.json (`author`, `homepage`, `repository`).
34
+ */
35
+ function generateSbom(projectRoot, { format = 'cyclonedx', creator } = {}) {
36
+ const parsed = parseLockfile(projectRoot);
37
+ if (!parsed.ok) {
38
+ return { ok: false, error: parsed.error };
39
+ }
40
+
41
+ const rootPkg = readJson(path.join(projectRoot, 'package.json')) || {};
42
+ const product = {
43
+ ...parsed.root,
44
+ purl: buildPurl(parsed.root.name, parsed.root.version),
45
+ creator: creatorFromManifest(rootPkg),
46
+ license: licenseFromManifest(rootPkg),
47
+ sourceUrl: repositoryUrl(rootPkg.repository),
48
+ };
49
+ const sbomCreator = parseCreatorOption(creator) || product.creator;
50
+ const components = parsed.components.map((c) => describeComponent(projectRoot, c));
51
+
52
+ const normalizedFormat = String(format).toLowerCase();
53
+ let document;
54
+ if (normalizedFormat === 'spdx') {
55
+ document = buildSpdx(product, components, sbomCreator);
56
+ } else if (normalizedFormat === 'cyclonedx' || normalizedFormat === 'cdx') {
57
+ document = buildCycloneDx(product, components, sbomCreator, parsed.dependencyGraphComplete);
58
+ } else {
59
+ return { ok: false, error: `Unsupported SBOM format: "${format}". Use "cyclonedx" or "spdx".` };
60
+ }
61
+
62
+ return {
63
+ ok: true,
64
+ format: normalizedFormat === 'spdx' ? 'spdx' : 'cyclonedx',
65
+ componentCount: components.length,
66
+ document,
67
+ };
68
+ }
69
+
70
+ /** Adds the TR-03183 fields that come from the installed package. */
71
+ function describeComponent(projectRoot, c) {
72
+ const manifest = readInstalledManifest(projectRoot, c);
73
+ return {
74
+ ...c,
75
+ key: componentKey(c.name, c.version),
76
+ license: c.license || licenseFromManifest(manifest),
77
+ creator: creatorFromManifest(manifest),
78
+ sourceUrl: manifest ? repositoryUrl(manifest.repository) : null,
79
+ downloadUrl: c.resolved && /^https?:\/\//.test(c.resolved) ? c.resolved.split('#')[0] : registryTarballUrl(c),
80
+ filename: tarballFilename(c),
81
+ };
82
+ }
83
+
84
+ /** Accepts an email address or an http(s) URL for the SBOM creator. */
85
+ function parseCreatorOption(value) {
86
+ if (!value || typeof value !== 'string') return null;
87
+ const v = value.trim();
88
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return { name: null, email: v, url: null };
89
+ if (/^https?:\/\//i.test(v)) return { name: null, email: null, url: v };
90
+ return null;
91
+ }
92
+
93
+ // --- CycloneDX 1.6 ---------------------------------------------------------
94
+
95
+ function buildCycloneDx(product, components, sbomCreator, graphComplete) {
96
+ const refByKey = new Map(components.map((c) => [c.key, bomRef(c)]));
97
+ const rootRef = product.purl || `${product.name}@${product.version}`;
98
+ let complete = graphComplete;
99
+
100
+ const refsFor = (keys) => keys.map((key) => {
101
+ const ref = refByKey.get(key);
102
+ if (!ref) complete = false; // an edge points outside the SBOM
103
+ return ref;
104
+ }).filter(Boolean);
105
+
106
+ const rootComponent = {
107
+ type: 'application',
108
+ 'bom-ref': rootRef,
109
+ name: product.name,
110
+ version: product.version,
111
+ purl: product.purl || undefined,
112
+ manufacturer: cdxEntity(product.creator),
113
+ licenses: cdxLicenses(product.license),
114
+ externalReferences: product.sourceUrl
115
+ ? [{ type: 'source-distribution', url: product.sourceUrl }]
116
+ : undefined,
117
+ };
118
+
119
+ const cdxComponents = components.map((c) => {
120
+ const hashes = c.hashAlgorithm && c.hashValue ? [{ alg: c.hashAlgorithm, content: c.hashValue }] : undefined;
121
+ const externalReferences = [];
122
+ if (c.downloadUrl) externalReferences.push({ type: 'distribution', url: c.downloadUrl, hashes });
123
+ if (c.sourceUrl) externalReferences.push({ type: 'source-distribution', url: c.sourceUrl });
124
+
125
+ return {
126
+ type: 'library',
127
+ 'bom-ref': bomRef(c),
128
+ name: c.name,
129
+ version: c.version || undefined,
130
+ scope: c.scope,
131
+ purl: c.purl || undefined,
132
+ manufacturer: cdxEntity(c.creator),
133
+ hashes,
134
+ licenses: cdxLicenses(c.license),
135
+ externalReferences: externalReferences.length ? externalReferences : undefined,
136
+ // An npm package is a gzipped tarball: a structured, non-executable archive.
137
+ properties: [
138
+ { name: 'bsi:component:filename', value: c.filename },
139
+ { name: 'bsi:component:executable', value: 'non-executable' },
140
+ { name: 'bsi:component:archive', value: 'archive' },
141
+ { name: 'bsi:component:structured', value: 'structured' },
142
+ ],
143
+ };
144
+ });
145
+
146
+ // Components without dependencies MUST still appear with an empty dependsOn.
147
+ const dependencies = [
148
+ { ref: rootRef, dependsOn: refsFor(product.dependsOn) },
149
+ ...components.map((c) => ({ ref: bomRef(c), dependsOn: refsFor(c.dependsOn) })),
150
+ ];
151
+
152
+ return {
153
+ bomFormat: 'CycloneDX',
154
+ specVersion: '1.6',
155
+ serialNumber: `urn:uuid:${crypto.randomUUID()}`,
156
+ version: 1,
157
+ metadata: {
158
+ timestamp: new Date().toISOString(),
159
+ tools: {
160
+ components: [{
161
+ type: 'application',
162
+ name: TOOL_NAME,
163
+ version: TOOL_VERSION,
164
+ externalReferences: [{ type: 'vcs', url: TOOL_URL }],
165
+ }],
166
+ },
167
+ manufacturer: cdxEntity(sbomCreator),
168
+ component: rootComponent,
169
+ },
170
+ components: cdxComponents,
171
+ dependencies,
172
+ compositions: [{
173
+ aggregate: complete ? 'complete' : 'incomplete',
174
+ dependencies: dependencies.map((d) => d.ref),
175
+ }],
176
+ };
177
+ }
178
+
179
+ function bomRef(c) {
180
+ return c.purl || c.key;
181
+ }
182
+
183
+ /** TR-03183 asks for an email address, or a URL when there is none. */
184
+ function cdxEntity(creator) {
185
+ if (!creator) return undefined;
186
+ const entity = {};
187
+ if (creator.name) entity.name = creator.name;
188
+ if (creator.email) entity.contact = [{ email: creator.email }];
189
+ else if (creator.url) entity.url = [creator.url];
190
+ else return undefined;
191
+ return entity;
192
+ }
193
+
194
+ /**
195
+ * License choice per TR-03183 appendix 8.5: the licence assigned by the
196
+ * component creator ("declared", the original licence) and the one it is
197
+ * distributed under ("concluded"). For npm packages both are the package.json
198
+ * license. SPDX expressions can only be given once, as the concluded licence.
199
+ */
200
+ function cdxLicenses(license) {
201
+ if (!license) return undefined;
202
+ const value = String(license).trim();
203
+ if (/\s(AND|OR|WITH)\s|[()]/i.test(value)) {
204
+ return [{ expression: value, acknowledgement: 'concluded' }];
205
+ }
206
+ // Unknown ids go in `name`: CycloneDX validates `id` against the SPDX list.
207
+ const license_ = SPDX_IDS.has(value) ? { id: value } : { name: value };
208
+ return [
209
+ { license: { ...license_, acknowledgement: 'declared' } },
210
+ { license: { ...license_, acknowledgement: 'concluded' } },
211
+ ];
212
+ }
213
+
214
+ // --- SPDX 2.3 --------------------------------------------------------------
215
+
216
+ function buildSpdx(product, components, sbomCreator) {
217
+ const rootSpdxId = 'SPDXRef-Package-root';
218
+ const idByKey = new Map(components.map((c, index) => [c.key, `SPDXRef-Package-${index}`]));
219
+
220
+ const packages = components.map((c) => {
221
+ const pkg = {
222
+ name: c.name,
223
+ SPDXID: idByKey.get(c.key),
224
+ versionInfo: c.version || 'NOASSERTION',
225
+ packageFileName: c.filename,
226
+ originator: spdxActor(c.creator),
227
+ downloadLocation: c.downloadUrl || 'NOASSERTION',
228
+ filesAnalyzed: false,
229
+ licenseConcluded: c.license || 'NOASSERTION',
230
+ licenseDeclared: c.license || 'NOASSERTION',
231
+ copyrightText: 'NOASSERTION',
232
+ externalRefs: c.purl
233
+ ? [{
234
+ referenceCategory: 'PACKAGE-MANAGER',
235
+ referenceType: 'purl',
236
+ referenceLocator: c.purl,
237
+ }]
238
+ : undefined,
239
+ };
240
+ if (c.hashAlgorithm && c.hashValue) {
241
+ pkg.checksums = [{ algorithm: c.hashAlgorithm.replace('-', ''), checksumValue: c.hashValue }];
242
+ }
243
+ return pkg;
244
+ });
245
+
246
+ packages.unshift({
247
+ name: product.name,
248
+ SPDXID: rootSpdxId,
249
+ versionInfo: product.version,
250
+ originator: spdxActor(product.creator),
251
+ downloadLocation: 'NOASSERTION',
252
+ filesAnalyzed: false,
253
+ licenseConcluded: product.license || 'NOASSERTION',
254
+ licenseDeclared: product.license || 'NOASSERTION',
255
+ copyrightText: 'NOASSERTION',
256
+ externalRefs: product.purl
257
+ ? [{ referenceCategory: 'PACKAGE-MANAGER', referenceType: 'purl', referenceLocator: product.purl }]
258
+ : undefined,
259
+ });
260
+
261
+ const relationships = [{
262
+ spdxElementId: 'SPDXRef-DOCUMENT',
263
+ relationshipType: 'DESCRIBES',
264
+ relatedSpdxElement: rootSpdxId,
265
+ }];
266
+ const addEdges = (fromId, keys) => {
267
+ for (const key of keys) {
268
+ const to = idByKey.get(key);
269
+ if (to) relationships.push({ spdxElementId: fromId, relationshipType: 'DEPENDS_ON', relatedSpdxElement: to });
270
+ }
271
+ };
272
+ addEdges(rootSpdxId, product.dependsOn);
273
+ for (const c of components) addEdges(idByKey.get(c.key), c.dependsOn);
274
+
275
+ const creators = [`Tool: ${TOOL_NAME}-${TOOL_VERSION}`];
276
+ const creatorActor = spdxActor(sbomCreator);
277
+ if (creatorActor) creators.unshift(creatorActor);
278
+
279
+ return {
280
+ spdxVersion: 'SPDX-2.3',
281
+ dataLicense: 'CC0-1.0',
282
+ SPDXID: 'SPDXRef-DOCUMENT',
283
+ name: `${product.name}@${product.version}`,
284
+ documentNamespace: `https://spdx.org/spdxdocs/${product.name}-${crypto.randomUUID()}`,
285
+ creationInfo: {
286
+ created: new Date().toISOString(),
287
+ creators,
288
+ },
289
+ documentDescribes: [rootSpdxId],
290
+ packages,
291
+ relationships,
292
+ };
293
+ }
294
+
295
+ /** SPDX 2.3 actor: "Organization: name (email)". */
296
+ function spdxActor(creator) {
297
+ if (!creator) return undefined;
298
+ const label = creator.name || creator.url || creator.email;
299
+ return `Organization: ${label} (${creator.email || ''})`;
300
+ }
301
+
302
+ // --- npm tarball helpers ---------------------------------------------------
303
+
304
+ /** The actual filename of the deployable component (TR-03183 §5.2.2). */
305
+ function tarballFilename(c) {
306
+ if (c.resolved && /^https?:\/\//.test(c.resolved)) {
307
+ const base = c.resolved.split(/[?#]/)[0].split('/').pop();
308
+ if (base) return base;
309
+ }
310
+ return `${c.name.split('/').pop()}-${c.version}.tgz`;
311
+ }
312
+
313
+ /** Canonical npm registry URL, used when the lockfile does not record one. */
314
+ function registryTarballUrl(c) {
315
+ if (!c.name || !c.version) return null;
316
+ return `${NPM_REGISTRY}/${c.name}/-/${c.name.split('/').pop()}-${c.version}.tgz`;
317
+ }
318
+
319
+ /** Common SPDX license ids that are safe to emit as CycloneDX `license.id`. */
320
+ const SPDX_IDS = new Set([
321
+ '0BSD', 'AFL-2.1', 'AFL-3.0', 'AGPL-3.0', 'AGPL-3.0-only', 'AGPL-3.0-or-later', 'Apache-1.1',
322
+ 'Apache-2.0', 'Artistic-2.0', 'BlueOak-1.0.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSL-1.0',
323
+ 'CC-BY-3.0', 'CC-BY-4.0', 'CC-BY-SA-4.0', 'CC0-1.0', 'CDDL-1.0', 'CDDL-1.1', 'EPL-1.0', 'EPL-2.0',
324
+ 'EUPL-1.1', 'EUPL-1.2', 'GPL-2.0', 'GPL-2.0-only', 'GPL-2.0-or-later', 'GPL-3.0', 'GPL-3.0-only',
325
+ 'GPL-3.0-or-later', 'ISC', 'LGPL-2.1', 'LGPL-2.1-only', 'LGPL-2.1-or-later', 'LGPL-3.0',
326
+ 'LGPL-3.0-only', 'LGPL-3.0-or-later', 'MIT', 'MIT-0', 'MPL-1.1', 'MPL-2.0', 'OFL-1.1', 'Python-2.0',
327
+ 'Unicode-3.0', 'Unicode-DFS-2016', 'Unlicense', 'UPL-1.0', 'W3C', 'WTFPL', 'Zlib',
328
+ ]);
329
+
330
+ module.exports = { generateSbom };