@cyclonedx/cdxgen 12.3.3 → 12.4.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.
Files changed (157) hide show
  1. package/README.md +64 -22
  2. package/bin/audit.js +21 -7
  3. package/bin/cdxgen.js +238 -116
  4. package/bin/convert.js +28 -13
  5. package/bin/hbom.js +490 -0
  6. package/bin/repl.js +580 -29
  7. package/bin/validate.js +34 -4
  8. package/bin/verify.js +40 -5
  9. package/data/README.md +298 -25
  10. package/data/component-tags.json +6 -0
  11. package/data/crypto-oid.json +16 -0
  12. package/data/predictive-audit-allowlist.json +11 -0
  13. package/data/queries-darwin.json +12 -1
  14. package/data/queries-win.json +7 -1
  15. package/data/queries.json +39 -2
  16. package/data/rules/ai-agent-governance.yaml +16 -0
  17. package/data/rules/asar-archives.yaml +150 -0
  18. package/data/rules/chrome-extensions.yaml +8 -0
  19. package/data/rules/ci-permissions.yaml +42 -18
  20. package/data/rules/container-risk.yaml +14 -7
  21. package/data/rules/dependency-sources.yaml +11 -0
  22. package/data/rules/hbom-compliance.yaml +325 -0
  23. package/data/rules/hbom-performance.yaml +307 -0
  24. package/data/rules/hbom-security.yaml +248 -0
  25. package/data/rules/host-topology.yaml +165 -0
  26. package/data/rules/mcp-servers.yaml +18 -3
  27. package/data/rules/obom-runtime.yaml +907 -22
  28. package/data/rules/package-integrity.yaml +14 -0
  29. package/data/rules/rootfs-hardening.yaml +179 -0
  30. package/data/rules/vscode-extensions.yaml +9 -0
  31. package/lib/audit/index.js +209 -8
  32. package/lib/audit/index.poku.js +332 -0
  33. package/lib/audit/reporters.js +222 -0
  34. package/lib/audit/targets.js +146 -1
  35. package/lib/audit/targets.poku.js +186 -0
  36. package/lib/cli/asar.poku.js +328 -0
  37. package/lib/cli/index.js +506 -88
  38. package/lib/cli/index.poku.js +1352 -212
  39. package/lib/evinser/evinser.js +14 -9
  40. package/lib/helpers/analyzer.js +1406 -29
  41. package/lib/helpers/analyzer.poku.js +342 -0
  42. package/lib/helpers/analyzerScope.js +712 -0
  43. package/lib/helpers/asarutils.js +1556 -0
  44. package/lib/helpers/asarutils.poku.js +443 -0
  45. package/lib/helpers/auditCategories.js +12 -0
  46. package/lib/helpers/auditCategories.poku.js +32 -0
  47. package/lib/helpers/cbomutils.js +271 -1
  48. package/lib/helpers/cbomutils.poku.js +248 -5
  49. package/lib/helpers/display.js +291 -1
  50. package/lib/helpers/display.poku.js +149 -0
  51. package/lib/helpers/evidenceUtils.js +58 -0
  52. package/lib/helpers/evidenceUtils.poku.js +54 -0
  53. package/lib/helpers/exportUtils.js +9 -0
  54. package/lib/helpers/gtfobins.js +142 -8
  55. package/lib/helpers/gtfobins.poku.js +24 -1
  56. package/lib/helpers/hbom.js +710 -0
  57. package/lib/helpers/hbom.poku.js +496 -0
  58. package/lib/helpers/hbomAnalysis.js +268 -0
  59. package/lib/helpers/hbomAnalysis.poku.js +249 -0
  60. package/lib/helpers/hbomLoader.js +35 -0
  61. package/lib/helpers/hostTopology.js +803 -0
  62. package/lib/helpers/hostTopology.poku.js +363 -0
  63. package/lib/helpers/inventoryStats.js +69 -0
  64. package/lib/helpers/inventoryStats.poku.js +86 -0
  65. package/lib/helpers/lolbas.js +19 -1
  66. package/lib/helpers/lolbas.poku.js +23 -0
  67. package/lib/helpers/osqueryTransform.js +47 -0
  68. package/lib/helpers/osqueryTransform.poku.js +47 -0
  69. package/lib/helpers/plugins.js +349 -0
  70. package/lib/helpers/plugins.poku.js +57 -0
  71. package/lib/helpers/protobom.js +156 -45
  72. package/lib/helpers/protobom.poku.js +140 -5
  73. package/lib/helpers/remote/dependency-track.js +36 -3
  74. package/lib/helpers/remote/dependency-track.poku.js +44 -0
  75. package/lib/helpers/source.js +24 -0
  76. package/lib/helpers/source.poku.js +32 -0
  77. package/lib/helpers/utils.js +1438 -93
  78. package/lib/helpers/utils.poku.js +846 -4
  79. package/lib/managers/binary.e2e.poku.js +367 -0
  80. package/lib/managers/binary.js +2293 -353
  81. package/lib/managers/binary.poku.js +1699 -1
  82. package/lib/managers/docker.js +201 -79
  83. package/lib/managers/docker.poku.js +337 -12
  84. package/lib/server/server.js +2 -27
  85. package/lib/stages/postgen/annotator.js +38 -0
  86. package/lib/stages/postgen/annotator.poku.js +107 -1
  87. package/lib/stages/postgen/auditBom.js +121 -18
  88. package/lib/stages/postgen/auditBom.poku.js +1366 -31
  89. package/lib/stages/postgen/hostTopologyAudit.poku.js +186 -0
  90. package/lib/stages/postgen/postgen.js +192 -1
  91. package/lib/stages/postgen/postgen.poku.js +321 -0
  92. package/lib/stages/postgen/ruleEngine.js +116 -0
  93. package/lib/stages/pregen/envAudit.js +14 -3
  94. package/package.json +23 -21
  95. package/types/bin/hbom.d.ts +3 -0
  96. package/types/bin/hbom.d.ts.map +1 -0
  97. package/types/bin/repl.d.ts.map +1 -1
  98. package/types/lib/audit/index.d.ts +44 -0
  99. package/types/lib/audit/index.d.ts.map +1 -1
  100. package/types/lib/audit/reporters.d.ts +16 -0
  101. package/types/lib/audit/reporters.d.ts.map +1 -1
  102. package/types/lib/audit/targets.d.ts.map +1 -1
  103. package/types/lib/cli/index.d.ts +16 -0
  104. package/types/lib/cli/index.d.ts.map +1 -1
  105. package/types/lib/evinser/evinser.d.ts +4 -0
  106. package/types/lib/evinser/evinser.d.ts.map +1 -1
  107. package/types/lib/helpers/analyzer.d.ts +33 -0
  108. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  109. package/types/lib/helpers/analyzerScope.d.ts +11 -0
  110. package/types/lib/helpers/analyzerScope.d.ts.map +1 -0
  111. package/types/lib/helpers/asarutils.d.ts +34 -0
  112. package/types/lib/helpers/asarutils.d.ts.map +1 -0
  113. package/types/lib/helpers/auditCategories.d.ts +5 -0
  114. package/types/lib/helpers/auditCategories.d.ts.map +1 -1
  115. package/types/lib/helpers/cbomutils.d.ts +3 -2
  116. package/types/lib/helpers/cbomutils.d.ts.map +1 -1
  117. package/types/lib/helpers/display.d.ts.map +1 -1
  118. package/types/lib/helpers/evidenceUtils.d.ts +8 -0
  119. package/types/lib/helpers/evidenceUtils.d.ts.map +1 -0
  120. package/types/lib/helpers/exportUtils.d.ts.map +1 -1
  121. package/types/lib/helpers/gtfobins.d.ts +8 -0
  122. package/types/lib/helpers/gtfobins.d.ts.map +1 -1
  123. package/types/lib/helpers/hbom.d.ts +49 -0
  124. package/types/lib/helpers/hbom.d.ts.map +1 -0
  125. package/types/lib/helpers/hbomAnalysis.d.ts +62 -0
  126. package/types/lib/helpers/hbomAnalysis.d.ts.map +1 -0
  127. package/types/lib/helpers/hbomLoader.d.ts +7 -0
  128. package/types/lib/helpers/hbomLoader.d.ts.map +1 -0
  129. package/types/lib/helpers/hostTopology.d.ts +12 -0
  130. package/types/lib/helpers/hostTopology.d.ts.map +1 -0
  131. package/types/lib/helpers/inventoryStats.d.ts +11 -0
  132. package/types/lib/helpers/inventoryStats.d.ts.map +1 -0
  133. package/types/lib/helpers/lolbas.d.ts.map +1 -1
  134. package/types/lib/helpers/osqueryTransform.d.ts +3 -0
  135. package/types/lib/helpers/osqueryTransform.d.ts.map +1 -1
  136. package/types/lib/helpers/plugins.d.ts +58 -0
  137. package/types/lib/helpers/plugins.d.ts.map +1 -0
  138. package/types/lib/helpers/protobom.d.ts +3 -4
  139. package/types/lib/helpers/protobom.d.ts.map +1 -1
  140. package/types/lib/helpers/remote/dependency-track.d.ts +10 -3
  141. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  142. package/types/lib/helpers/source.d.ts.map +1 -1
  143. package/types/lib/helpers/utils.d.ts +45 -8
  144. package/types/lib/helpers/utils.d.ts.map +1 -1
  145. package/types/lib/managers/binary.d.ts +5 -0
  146. package/types/lib/managers/binary.d.ts.map +1 -1
  147. package/types/lib/managers/docker.d.ts.map +1 -1
  148. package/types/lib/server/server.d.ts +2 -1
  149. package/types/lib/server/server.d.ts.map +1 -1
  150. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  151. package/types/lib/stages/postgen/auditBom.d.ts +26 -1
  152. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  153. package/types/lib/stages/postgen/postgen.d.ts +2 -1
  154. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  155. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  156. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  157. package/data/spdx-model-v3.0.1.jsonld +0 -15999
@@ -1,11 +1,14 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import {
3
+ copyFileSync,
4
+ existsSync,
3
5
  mkdirSync,
4
6
  mkdtempSync,
5
7
  readFileSync,
6
8
  rmSync,
7
9
  writeFileSync,
8
10
  } from "node:fs";
11
+ import { createServer } from "node:http";
9
12
  import { tmpdir } from "node:os";
10
13
  import { dirname, join, normalize, sep } from "node:path";
11
14
  import process from "node:process";
@@ -15,6 +18,12 @@ import esmock from "esmock";
15
18
  import { assert, describe, it } from "poku";
16
19
  import sinon from "sinon";
17
20
 
21
+ import { readBinary } from "../helpers/protobom.js";
22
+ import {
23
+ getRecordedActivities,
24
+ resetRecordedActivities,
25
+ setDryRunMode,
26
+ } from "../helpers/utils.js";
18
27
  import { auditBom } from "../stages/postgen/auditBom.js";
19
28
  import { postProcess } from "../stages/postgen/postgen.js";
20
29
  import {
@@ -22,7 +31,10 @@ import {
22
31
  createChromeExtensionBom,
23
32
  createNodejsBom,
24
33
  createPHPBom,
34
+ createPythonBom,
25
35
  createRustBom,
36
+ listComponents,
37
+ submitBom,
26
38
  } from "./index.js";
27
39
 
28
40
  const fixtureDir = join(
@@ -60,6 +72,14 @@ const mcpFixtureDir = join(
60
72
  "data",
61
73
  "mcp-repotest",
62
74
  );
75
+ const cbomFixtureDir = join(
76
+ dirname(fileURLToPath(import.meta.url)),
77
+ "..",
78
+ "..",
79
+ "test",
80
+ "data",
81
+ "cbom-js-repotest",
82
+ );
63
83
  const cacheDisableFixtureDir = join(
64
84
  dirname(fileURLToPath(import.meta.url)),
65
85
  "..",
@@ -149,7 +169,125 @@ function getNpmPackFilePaths() {
149
169
  return packSummary.files.map((file) => toPortablePath(file.path));
150
170
  }
151
171
 
172
+ function buildMinimalCliEnv(extraEnv = {}) {
173
+ const baseEnv = {
174
+ HOME: process.env.HOME,
175
+ PATH: process.env.PATH,
176
+ TMPDIR: process.env.TMPDIR,
177
+ };
178
+ if (process.platform === "win32") {
179
+ baseEnv.SystemRoot = process.env.SystemRoot;
180
+ baseEnv.TEMP = process.env.TEMP;
181
+ baseEnv.TMP = process.env.TMP;
182
+ baseEnv.USERPROFILE = process.env.USERPROFILE;
183
+ }
184
+ return Object.fromEntries(
185
+ Object.entries({
186
+ ...baseEnv,
187
+ ...extraEnv,
188
+ }).filter(([, value]) => value !== undefined),
189
+ );
190
+ }
191
+
192
+ async function startSubmitBomTestServer(requestHandler) {
193
+ const requests = [];
194
+ const server = createServer((req, res) => {
195
+ let body = "";
196
+ req.setEncoding("utf8");
197
+ req.on("data", (chunk) => {
198
+ body += chunk;
199
+ });
200
+ req.on("end", async () => {
201
+ const request = {
202
+ body,
203
+ headers: req.headers,
204
+ rawHeaders: req.rawHeaders,
205
+ method: req.method,
206
+ url: req.url,
207
+ };
208
+ requests.push(request);
209
+ const response = (await requestHandler(request, requests.length)) || {};
210
+ if (res.writableEnded) {
211
+ return;
212
+ }
213
+ res.writeHead(response.statusCode || 200, {
214
+ "Content-Type": "application/json",
215
+ });
216
+ res.end(JSON.stringify(response.body || { success: true }));
217
+ });
218
+ });
219
+ await new Promise((resolve) => {
220
+ server.listen(0, "127.0.0.1", resolve);
221
+ });
222
+ const address = server.address();
223
+ const serverUrl = `http://127.0.0.1:${address.port}`;
224
+ return {
225
+ close: () =>
226
+ new Promise((resolve, reject) => {
227
+ server.close((error) => {
228
+ if (error) {
229
+ reject(error);
230
+ return;
231
+ }
232
+ resolve();
233
+ });
234
+ }),
235
+ requests,
236
+ serverUrl,
237
+ };
238
+ }
239
+
240
+ function getRequestHeader(request, headerName) {
241
+ const normalizedHeaderName = headerName.toLowerCase();
242
+ const directValue = request?.headers?.[normalizedHeaderName];
243
+ if (directValue !== undefined) {
244
+ return Array.isArray(directValue) ? directValue[0] : directValue;
245
+ }
246
+ const rawHeaders = request?.rawHeaders;
247
+ if (!Array.isArray(rawHeaders)) {
248
+ return undefined;
249
+ }
250
+ for (let index = 0; index < rawHeaders.length; index += 2) {
251
+ if (rawHeaders[index]?.toLowerCase() === normalizedHeaderName) {
252
+ return rawHeaders[index + 1];
253
+ }
254
+ }
255
+ return undefined;
256
+ }
257
+
152
258
  describe("CLI tests", () => {
259
+ describe("component creation", () => {
260
+ it("keeps readable OBOM bom-refs when no package purl type is available", () => {
261
+ const components = listComponents(
262
+ { specVersion: 1.7 },
263
+ undefined,
264
+ [
265
+ {
266
+ "bom-ref":
267
+ "osquery:authorized_keys_snapshot:data:root@ssh-ed25519[key_file=/root/.ssh/authorized_keys]",
268
+ name: "root",
269
+ properties: [
270
+ {
271
+ name: "cdx:osquery:category",
272
+ value: "authorized_keys_snapshot",
273
+ },
274
+ ],
275
+ type: "data",
276
+ version: "ssh-ed25519",
277
+ },
278
+ ],
279
+ "",
280
+ );
281
+ assert.strictEqual(components.length, 1);
282
+ assert.strictEqual(components[0].purl, undefined);
283
+ assert.strictEqual(
284
+ components[0]["bom-ref"],
285
+ "osquery:authorized_keys_snapshot:data:root@ssh-ed25519[key_file=/root/.ssh/authorized_keys]",
286
+ );
287
+ assert.strictEqual(components[0].type, "data");
288
+ });
289
+ });
290
+
153
291
  describe("distribution filters", () => {
154
292
  it("keeps npm types while excluding poku tests from npm pack output", () => {
155
293
  const packedPaths = getNpmPackFilePaths();
@@ -169,6 +307,443 @@ describe("CLI tests", () => {
169
307
  });
170
308
  });
171
309
 
310
+ describe("dry-run tracing", () => {
311
+ it("captures sensitive file reads and environment reads for private registry style Docker inputs", () => {
312
+ const fixtureRoot = mkdtempSync(
313
+ join(tmpdir(), "cdxgen-dry-run-registry-"),
314
+ );
315
+ const dockerConfigDir = join(fixtureRoot, "docker-config");
316
+ mkdirSync(dockerConfigDir, { recursive: true });
317
+ writeFileSync(
318
+ join(dockerConfigDir, "config.json"),
319
+ JSON.stringify({
320
+ credHelpers: {
321
+ "docker.io": "osxkeychain",
322
+ },
323
+ }),
324
+ );
325
+ try {
326
+ const output = execFileSync(
327
+ process.execPath,
328
+ [
329
+ join(repoDir, "bin", "cdxgen.js"),
330
+ "--dry-run",
331
+ "-t",
332
+ "oci",
333
+ "docker.io/library/alpine:3.20",
334
+ "--no-banner",
335
+ ],
336
+ {
337
+ cwd: repoDir,
338
+ encoding: "utf8",
339
+ env: buildMinimalCliEnv({
340
+ DOCKER_CONFIG: dockerConfigDir,
341
+ }),
342
+ },
343
+ );
344
+ assert.match(output, /cdxgen dry-run activity summary/);
345
+ assert.match(output, /process\.env:DOCKER_CONFIG/);
346
+ } finally {
347
+ rmSync(fixtureRoot, { force: true, recursive: true });
348
+ }
349
+ });
350
+
351
+ it("supports bom audit in dry-run mode while skipping predictive dependency analysis", () => {
352
+ const result = spawnSync(
353
+ process.execPath,
354
+ [
355
+ join(repoDir, "bin", "cdxgen.js"),
356
+ "--dry-run",
357
+ "--bom-audit",
358
+ "--bom-audit-categories",
359
+ "mcp-server",
360
+ "-t",
361
+ "js",
362
+ mcpFixtureDir,
363
+ "--no-banner",
364
+ ],
365
+ {
366
+ cwd: repoDir,
367
+ encoding: "utf8",
368
+ env: buildMinimalCliEnv(),
369
+ },
370
+ );
371
+ assert.strictEqual(result.status, 0);
372
+ const output = `${result.stdout}${result.stderr}`;
373
+
374
+ assert.match(output, /BOM Audit Findings/);
375
+ assert.match(output, /MCP-001/);
376
+ assert.match(
377
+ output,
378
+ /Dry-run mode only planned predictive audit targets/i,
379
+ );
380
+ });
381
+
382
+ it("enforces CDXGEN_ALLOWED_HOSTS for Dependency-Track submission in secure CLI mode", () => {
383
+ const result = spawnSync(
384
+ process.execPath,
385
+ [
386
+ join(repoDir, "bin", "cdxgen.js"),
387
+ "--dry-run",
388
+ "-t",
389
+ "js",
390
+ mcpFixtureDir,
391
+ "--server-url",
392
+ "https://blocked.example.com",
393
+ "--api-key",
394
+ "test-api-key",
395
+ "--no-banner",
396
+ ],
397
+ {
398
+ cwd: repoDir,
399
+ encoding: "utf8",
400
+ env: buildMinimalCliEnv({
401
+ CDXGEN_ALLOWED_HOSTS: "allowed.example.com",
402
+ CDXGEN_SECURE_MODE: "true",
403
+ }),
404
+ },
405
+ );
406
+ const output = `${result.stdout}${result.stderr}`;
407
+
408
+ assert.strictEqual(result.status, 1);
409
+ assert.match(
410
+ output,
411
+ /Dependency-Track server host 'blocked\.example\.com' is not allowed/i,
412
+ );
413
+ });
414
+ });
415
+
416
+ describe("protobuf CLI round-trip", () => {
417
+ it("generates, converts, and validates protobuf BOMs for CycloneDX 1.6 and 1.7", () => {
418
+ const fixtureRoot = mkdtempSync(
419
+ join(tmpdir(), "cdxgen-proto-roundtrip-"),
420
+ );
421
+ try {
422
+ for (const specVersion of ["1.6", "1.7"]) {
423
+ const jsonPath = join(fixtureRoot, `bom-${specVersion}.json`);
424
+ const protoPath = join(fixtureRoot, `bom-${specVersion}.cdx`);
425
+ const spdxPath = join(fixtureRoot, `bom-${specVersion}.spdx.json`);
426
+ const generateResult = spawnSync(
427
+ process.execPath,
428
+ [
429
+ join(repoDir, "bin", "cdxgen.js"),
430
+ "-t",
431
+ "js",
432
+ mcpFixtureDir,
433
+ "-o",
434
+ jsonPath,
435
+ "--spec-version",
436
+ specVersion,
437
+ "--export-proto",
438
+ "--proto-bin-file",
439
+ protoPath,
440
+ "--no-banner",
441
+ ],
442
+ {
443
+ cwd: repoDir,
444
+ encoding: "utf8",
445
+ env: buildMinimalCliEnv(),
446
+ },
447
+ );
448
+ assert.strictEqual(generateResult.status, 0);
449
+ assert.ok(existsSync(jsonPath));
450
+ assert.ok(existsSync(protoPath));
451
+
452
+ const convertResult = spawnSync(
453
+ process.execPath,
454
+ [
455
+ join(repoDir, "bin", "convert.js"),
456
+ "-i",
457
+ protoPath,
458
+ "-o",
459
+ spdxPath,
460
+ ],
461
+ {
462
+ cwd: repoDir,
463
+ encoding: "utf8",
464
+ env: buildMinimalCliEnv(),
465
+ },
466
+ );
467
+ assert.strictEqual(convertResult.status, 0);
468
+ assert.ok(existsSync(spdxPath));
469
+
470
+ const validateResult = spawnSync(
471
+ process.execPath,
472
+ [
473
+ join(repoDir, "bin", "validate.js"),
474
+ "-i",
475
+ protoPath,
476
+ "--fail-severity",
477
+ "critical",
478
+ "--no-deep",
479
+ "--report",
480
+ "json",
481
+ ],
482
+ {
483
+ cwd: repoDir,
484
+ encoding: "utf8",
485
+ env: buildMinimalCliEnv(),
486
+ },
487
+ );
488
+ assert.strictEqual(validateResult.status, 0);
489
+ assert.doesNotMatch(
490
+ `${validateResult.stdout}${validateResult.stderr}`,
491
+ /Failed to parse|non-CycloneDX|Unsupported CycloneDX specVersion/i,
492
+ );
493
+ }
494
+ } finally {
495
+ rmSync(fixtureRoot, { force: true, recursive: true });
496
+ }
497
+ });
498
+
499
+ it("preserves user output directories for research-profile protobuf exports", () => {
500
+ const fixtureRoot = mkdtempSync(
501
+ join(tmpdir(), "cdxgen-proto-research-roundtrip-"),
502
+ );
503
+ try {
504
+ const jsonPath = join(fixtureRoot, "research.json");
505
+ const protoPath = join(fixtureRoot, "research.cdx");
506
+ const generateResult = spawnSync(
507
+ process.execPath,
508
+ [
509
+ join(repoDir, "bin", "cdxgen.js"),
510
+ "-t",
511
+ "js",
512
+ "-t",
513
+ "mcp",
514
+ mcpFixtureDir,
515
+ "--profile",
516
+ "research",
517
+ "-o",
518
+ jsonPath,
519
+ "--export-proto",
520
+ "--proto-bin-file",
521
+ protoPath,
522
+ "--no-banner",
523
+ ],
524
+ {
525
+ cwd: repoDir,
526
+ encoding: "utf8",
527
+ env: buildMinimalCliEnv(),
528
+ },
529
+ );
530
+
531
+ assert.strictEqual(generateResult.status, 0);
532
+ assert.ok(existsSync(fixtureRoot));
533
+ assert.ok(existsSync(jsonPath));
534
+ assert.ok(existsSync(protoPath));
535
+
536
+ const generatedBom = JSON.parse(readFileSync(jsonPath, "utf8"));
537
+ assert.ok((generatedBom.services || []).length >= 1);
538
+
539
+ const roundTrippedBom = readBinary(protoPath);
540
+ assert.ok(roundTrippedBom);
541
+ assert.ok((roundTrippedBom.formulation || []).length >= 1);
542
+ assert.ok((roundTrippedBom.services || []).length >= 1);
543
+ } finally {
544
+ rmSync(fixtureRoot, { force: true, recursive: true });
545
+ }
546
+ });
547
+
548
+ it("exports standards-enabled BOMs to protobuf using canonical definitions objects", () => {
549
+ const fixtureRoot = mkdtempSync(
550
+ join(tmpdir(), "cdxgen-proto-standards-roundtrip-"),
551
+ );
552
+ try {
553
+ const jsonPath = join(fixtureRoot, "standards.json");
554
+ const protoPath = join(fixtureRoot, "standards.cdx");
555
+ const generateResult = spawnSync(
556
+ process.execPath,
557
+ [
558
+ join(repoDir, "bin", "cdxgen.js"),
559
+ "-t",
560
+ "js",
561
+ mcpFixtureDir,
562
+ "--standard",
563
+ "asvs-5.0",
564
+ "-o",
565
+ jsonPath,
566
+ "--export-proto",
567
+ "--proto-bin-file",
568
+ protoPath,
569
+ "--no-banner",
570
+ ],
571
+ {
572
+ cwd: repoDir,
573
+ encoding: "utf8",
574
+ env: buildMinimalCliEnv(),
575
+ },
576
+ );
577
+
578
+ assert.strictEqual(generateResult.status, 0);
579
+ assert.ok(existsSync(jsonPath));
580
+ assert.ok(existsSync(protoPath));
581
+
582
+ const roundTrippedBom = readBinary(protoPath);
583
+ assert.ok(roundTrippedBom);
584
+ assert.equal(Array.isArray(roundTrippedBom.definitions), false);
585
+ assert.ok((roundTrippedBom.definitions?.standards || []).length >= 1);
586
+ } finally {
587
+ rmSync(fixtureRoot, { force: true, recursive: true });
588
+ }
589
+ });
590
+
591
+ it("round-trips research, standards, and CBOM protobuf exports with canonical JSON", () => {
592
+ const fixtureRoot = mkdtempSync(
593
+ join(tmpdir(), "cdxgen-proto-mode-roundtrip-"),
594
+ );
595
+ const scenarios = [
596
+ {
597
+ args: [
598
+ "-t",
599
+ "js",
600
+ "-t",
601
+ "mcp",
602
+ mcpFixtureDir,
603
+ "--profile",
604
+ "research",
605
+ ],
606
+ assertRoundTrip: (bomJson) => {
607
+ assert.ok((bomJson.formulation || []).length >= 1);
608
+ },
609
+ expectedSpecVersion: (specVersion) => specVersion,
610
+ name: "research",
611
+ },
612
+ {
613
+ args: [cbomFixtureDir, "--include-crypto", "--evidence", "--deep"],
614
+ assertRoundTrip: (bomJson) => {
615
+ const cryptoComponents = (bomJson.components || []).filter(
616
+ (component) => component.type === "cryptographic-asset",
617
+ );
618
+ assert.ok(cryptoComponents.length >= 3);
619
+ assert.equal(
620
+ cryptoComponents.some(
621
+ (component) => component.purl !== undefined,
622
+ ),
623
+ false,
624
+ );
625
+ },
626
+ expectedSpecVersion: (specVersion) => specVersion,
627
+ name: "cbom",
628
+ },
629
+ {
630
+ args: ["-t", "js", mcpFixtureDir, "--standard", "asvs-5.0"],
631
+ assertRoundTrip: (bomJson) => {
632
+ assert.equal(Array.isArray(bomJson.definitions), false);
633
+ assert.ok((bomJson.definitions?.standards || []).length >= 1);
634
+ },
635
+ expectedSpecVersion: () => "1.7",
636
+ name: "standards",
637
+ },
638
+ ];
639
+ try {
640
+ for (const scenario of scenarios) {
641
+ for (const specVersion of ["1.6", "1.7"]) {
642
+ const jsonPath = join(
643
+ fixtureRoot,
644
+ `${scenario.name}-${specVersion}.json`,
645
+ );
646
+ const protoPath = join(
647
+ fixtureRoot,
648
+ `${scenario.name}-${specVersion}.cdx`,
649
+ );
650
+ const spdxPath = join(
651
+ fixtureRoot,
652
+ `${scenario.name}-${specVersion}.spdx.json`,
653
+ );
654
+ const generateResult = spawnSync(
655
+ process.execPath,
656
+ [
657
+ join(repoDir, "bin", "cdxgen.js"),
658
+ ...scenario.args,
659
+ "-o",
660
+ jsonPath,
661
+ "--spec-version",
662
+ specVersion,
663
+ "--export-proto",
664
+ "--proto-bin-file",
665
+ protoPath,
666
+ "--no-banner",
667
+ ],
668
+ {
669
+ cwd: repoDir,
670
+ encoding: "utf8",
671
+ env: buildMinimalCliEnv(),
672
+ },
673
+ );
674
+ assert.strictEqual(
675
+ generateResult.status,
676
+ 0,
677
+ `${scenario.name} ${specVersion}: ${generateResult.stdout}${generateResult.stderr}`,
678
+ );
679
+
680
+ const generatedBom = JSON.parse(readFileSync(jsonPath, "utf8"));
681
+ assert.strictEqual(
682
+ generatedBom.specVersion,
683
+ scenario.expectedSpecVersion(specVersion),
684
+ );
685
+
686
+ const convertResult = spawnSync(
687
+ process.execPath,
688
+ [
689
+ join(repoDir, "bin", "convert.js"),
690
+ "-i",
691
+ protoPath,
692
+ "-o",
693
+ spdxPath,
694
+ ],
695
+ {
696
+ cwd: repoDir,
697
+ encoding: "utf8",
698
+ env: buildMinimalCliEnv(),
699
+ },
700
+ );
701
+ assert.strictEqual(
702
+ convertResult.status,
703
+ 0,
704
+ `${scenario.name} ${specVersion}: ${convertResult.stdout}${convertResult.stderr}`,
705
+ );
706
+
707
+ const validateResult = spawnSync(
708
+ process.execPath,
709
+ [
710
+ join(repoDir, "bin", "validate.js"),
711
+ "-i",
712
+ protoPath,
713
+ "--fail-severity",
714
+ "critical",
715
+ "--no-deep",
716
+ "--report",
717
+ "json",
718
+ ],
719
+ {
720
+ cwd: repoDir,
721
+ encoding: "utf8",
722
+ env: buildMinimalCliEnv(),
723
+ },
724
+ );
725
+ assert.strictEqual(
726
+ validateResult.status,
727
+ 0,
728
+ `${scenario.name} ${specVersion}: ${validateResult.stdout}${validateResult.stderr}`,
729
+ );
730
+
731
+ const roundTrippedBom = readBinary(protoPath);
732
+ assert.ok(roundTrippedBom);
733
+ assert.strictEqual(roundTrippedBom.bomFormat, "CycloneDX");
734
+ assert.strictEqual(
735
+ roundTrippedBom.specVersion,
736
+ scenario.expectedSpecVersion(specVersion),
737
+ );
738
+ scenario.assertRoundTrip(roundTrippedBom);
739
+ }
740
+ }
741
+ } finally {
742
+ rmSync(fixtureRoot, { force: true, recursive: true });
743
+ }
744
+ });
745
+ });
746
+
172
747
  describe("submitBom()", () => {
173
748
  it("should report blocked Dependency-Track submission during dry-run", async () => {
174
749
  const recordActivity = sinon.stub();
@@ -201,18 +776,11 @@ describe("CLI tests", () => {
201
776
  });
202
777
 
203
778
  it("should successfully report the SBOM with given project id, name, version and a single tag", async () => {
204
- const fakeGotResponse = {
205
- json: sinon.stub().resolves({ success: true }),
206
- };
207
-
208
- const gotStub = sinon.stub().returns(fakeGotResponse);
209
- gotStub.extend = sinon.stub().returns(gotStub);
210
-
211
- const { submitBom } = await esmock("./index.js", {
212
- got: { default: gotStub },
213
- });
779
+ const server = await startSubmitBomTestServer(async () => ({
780
+ body: { success: true },
781
+ }));
214
782
 
215
- const serverUrl = "https://dtrack.example.com";
783
+ const serverUrl = server.serverUrl;
216
784
  const projectId = "f7cb9f02-8041-4991-9101-b01fa07a6522";
217
785
  const projectName = "cdxgen-test-project";
218
786
  const projectVersion = "1.0.0";
@@ -230,47 +798,44 @@ describe("CLI tests", () => {
230
798
  projectTags: [{ name: projectTag }],
231
799
  };
232
800
 
233
- await submitBom(
234
- {
235
- serverUrl,
236
- projectId,
237
- projectName,
238
- projectVersion,
239
- apiKey,
240
- skipDtTlsCheck,
241
- projectTag,
242
- },
243
- bomContent,
244
- );
245
-
246
- // Verify got was called exactly once
247
- sinon.assert.calledOnce(gotStub);
248
-
249
- // Grab call arguments
250
- const [calledUrl, options] = gotStub.firstCall.args;
801
+ try {
802
+ const response = await submitBom(
803
+ {
804
+ serverUrl,
805
+ projectId,
806
+ projectName,
807
+ projectVersion,
808
+ apiKey,
809
+ skipDtTlsCheck,
810
+ projectTag,
811
+ },
812
+ bomContent,
813
+ );
251
814
 
252
- assert.equal(calledUrl, `${serverUrl}/api/v1/bom`);
253
- assert.equal(options.method, "PUT");
254
- assert.equal(options.followRedirect, false);
255
- assert.equal(options.https.rejectUnauthorized, !skipDtTlsCheck);
256
- assert.equal(options.headers["X-Api-Key"], apiKey);
257
- assert.match(options.headers["user-agent"], /@CycloneDX\/cdxgen/);
258
- assert.deepEqual(options.json, expectedRequestPayload);
815
+ assert.deepEqual(response, { success: true });
816
+ assert.equal(server.requests.length, 1);
817
+ assert.equal(server.requests[0].method, "PUT");
818
+ assert.equal(server.requests[0].url, "/api/v1/bom");
819
+ assert.equal(getRequestHeader(server.requests[0], "x-api-key"), apiKey);
820
+ assert.equal(
821
+ getRequestHeader(server.requests[0], "content-type"),
822
+ "application/json",
823
+ );
824
+ assert.deepEqual(
825
+ JSON.parse(server.requests[0].body),
826
+ expectedRequestPayload,
827
+ );
828
+ } finally {
829
+ await server.close();
830
+ }
259
831
  });
260
832
 
261
833
  it("should successfully report the SBOM with given parent project, name, version and multiple tags", async () => {
262
- const fakeGotResponse = {
263
- json: sinon.stub().resolves({ success: true }),
264
- };
265
-
266
- const gotStub = sinon.stub().returns(fakeGotResponse);
267
- gotStub.extend = sinon.stub().returns(gotStub);
834
+ const server = await startSubmitBomTestServer(async () => ({
835
+ body: { success: true },
836
+ }));
268
837
 
269
- const { submitBom } = await esmock("./index.js", {
270
- got: { default: gotStub },
271
- });
272
-
273
- const serverUrl = "https://dtrack.example.com";
838
+ const serverUrl = server.serverUrl;
274
839
  const projectName = "cdxgen-test-project";
275
840
  const projectVersion = "1.1.0";
276
841
  const projectTags = ["tag1", "tag2"];
@@ -290,48 +855,44 @@ describe("CLI tests", () => {
290
855
  projectTags: [{ name: projectTags[0] }, { name: projectTags[1] }],
291
856
  };
292
857
 
293
- await submitBom(
294
- {
295
- serverUrl,
296
- parentProjectId,
297
- projectName,
298
- projectVersion,
299
- apiKey,
300
- skipDtTlsCheck,
301
- projectTag: projectTags,
302
- },
303
- bomContent,
304
- );
305
-
306
- // Verify got was called exactly once
307
- sinon.assert.calledOnce(gotStub);
308
-
309
- // Grab call arguments
310
- const [calledUrl, options] = gotStub.firstCall.args;
858
+ try {
859
+ const response = await submitBom(
860
+ {
861
+ serverUrl,
862
+ parentProjectId,
863
+ projectName,
864
+ projectVersion,
865
+ apiKey,
866
+ skipDtTlsCheck,
867
+ projectTag: projectTags,
868
+ },
869
+ bomContent,
870
+ );
311
871
 
312
- // Assert call arguments against expectations
313
- assert.equal(calledUrl, `${serverUrl}/api/v1/bom`);
314
- assert.equal(options.method, "PUT");
315
- assert.equal(options.followRedirect, false);
316
- assert.equal(options.https.rejectUnauthorized, !skipDtTlsCheck);
317
- assert.equal(options.headers["X-Api-Key"], apiKey);
318
- assert.match(options.headers["user-agent"], /@CycloneDX\/cdxgen/);
319
- assert.deepEqual(options.json, expectedRequestPayload);
872
+ assert.deepEqual(response, { success: true });
873
+ assert.equal(server.requests.length, 1);
874
+ assert.equal(server.requests[0].method, "PUT");
875
+ assert.equal(server.requests[0].url, "/api/v1/bom");
876
+ assert.equal(getRequestHeader(server.requests[0], "x-api-key"), apiKey);
877
+ assert.equal(
878
+ getRequestHeader(server.requests[0], "content-type"),
879
+ "application/json",
880
+ );
881
+ assert.deepEqual(
882
+ JSON.parse(server.requests[0].body),
883
+ expectedRequestPayload,
884
+ );
885
+ } finally {
886
+ await server.close();
887
+ }
320
888
  });
321
889
 
322
890
  it("should include parentName and parentVersion when parent project name and version are passed", async () => {
323
- const fakeGotResponse = {
324
- json: sinon.stub().resolves({ success: true }),
325
- };
891
+ const server = await startSubmitBomTestServer(async () => ({
892
+ body: { success: true },
893
+ }));
326
894
 
327
- const gotStub = sinon.stub().returns(fakeGotResponse);
328
- gotStub.extend = sinon.stub().returns(gotStub);
329
-
330
- const { submitBom } = await esmock("./index.js", {
331
- got: { default: gotStub },
332
- });
333
-
334
- const serverUrl = "https://dtrack.example.com";
895
+ const serverUrl = server.serverUrl;
335
896
  const projectName = "cdxgen-test-project";
336
897
  const projectVersion = "2.0.0";
337
898
  const parentProjectName = "parent-project";
@@ -351,77 +912,71 @@ describe("CLI tests", () => {
351
912
  projectVersion,
352
913
  };
353
914
 
354
- await submitBom(
355
- {
356
- serverUrl,
357
- projectName,
358
- projectVersion,
359
- parentProjectName,
360
- parentProjectVersion,
361
- apiKey,
362
- skipDtTlsCheck,
363
- },
364
- bomContent,
365
- );
366
-
367
- sinon.assert.calledOnce(gotStub);
368
- const [calledUrl, options] = gotStub.firstCall.args;
915
+ try {
916
+ const response = await submitBom(
917
+ {
918
+ serverUrl,
919
+ projectName,
920
+ projectVersion,
921
+ parentProjectName,
922
+ parentProjectVersion,
923
+ apiKey,
924
+ skipDtTlsCheck,
925
+ },
926
+ bomContent,
927
+ );
369
928
 
370
- assert.equal(calledUrl, `${serverUrl}/api/v1/bom`);
371
- assert.equal(options.method, "PUT");
372
- assert.equal(options.followRedirect, false);
373
- assert.equal(options.https.rejectUnauthorized, !skipDtTlsCheck);
374
- assert.equal(options.headers["X-Api-Key"], apiKey);
375
- assert.match(options.headers["user-agent"], /@CycloneDX\/cdxgen/);
376
- assert.deepEqual(options.json, expectedRequestPayload);
929
+ assert.deepEqual(response, { success: true });
930
+ assert.equal(server.requests.length, 1);
931
+ assert.equal(server.requests[0].method, "PUT");
932
+ assert.equal(server.requests[0].url, "/api/v1/bom");
933
+ assert.equal(getRequestHeader(server.requests[0], "x-api-key"), apiKey);
934
+ assert.equal(
935
+ getRequestHeader(server.requests[0], "content-type"),
936
+ "application/json",
937
+ );
938
+ assert.deepEqual(
939
+ JSON.parse(server.requests[0].body),
940
+ expectedRequestPayload,
941
+ );
942
+ } finally {
943
+ await server.close();
944
+ }
377
945
  });
378
946
 
379
947
  it("should include configurable autoCreate and isLatest values in payload", async () => {
380
- const fakeGotResponse = {
381
- json: sinon.stub().resolves({ success: true }),
382
- };
948
+ const server = await startSubmitBomTestServer(async () => ({
949
+ body: { success: true },
950
+ }));
383
951
 
384
- const gotStub = sinon.stub().returns(fakeGotResponse);
385
- gotStub.extend = sinon.stub().returns(gotStub);
386
-
387
- const { submitBom } = await esmock("./index.js", {
388
- got: { default: gotStub },
389
- });
390
-
391
- const serverUrl = "https://dtrack.example.com";
952
+ const serverUrl = server.serverUrl;
392
953
  const projectName = "cdxgen-test-project";
393
954
  const apiKey = "TEST_API_KEY";
394
955
 
395
- await submitBom(
396
- {
397
- serverUrl,
398
- projectName,
399
- apiKey,
400
- autoCreate: false,
401
- isLatest: true,
402
- },
403
- { bom: "test4" },
404
- );
956
+ try {
957
+ const response = await submitBom(
958
+ {
959
+ serverUrl,
960
+ projectName,
961
+ apiKey,
962
+ autoCreate: false,
963
+ isLatest: true,
964
+ },
965
+ { bom: "test4" },
966
+ );
405
967
 
406
- sinon.assert.calledOnce(gotStub);
407
- const [_calledUrl, options] = gotStub.firstCall.args;
408
- assert.equal(options.json.autoCreate, "false");
409
- assert.equal(options.json.isLatest, true);
410
- assert.equal(options.json.projectVersion, "main");
968
+ assert.deepEqual(response, { success: true });
969
+ assert.equal(server.requests.length, 1);
970
+ const payload = JSON.parse(server.requests[0].body);
971
+ assert.equal(payload.autoCreate, "false");
972
+ assert.equal(payload.isLatest, true);
973
+ assert.equal(payload.projectVersion, "main");
974
+ } finally {
975
+ await server.close();
976
+ }
411
977
  });
412
978
 
413
979
  it("should reject invalid mixed parent modes before making network request", async () => {
414
- const fakeGotResponse = {
415
- json: sinon.stub().resolves({ success: true }),
416
- };
417
-
418
- const gotStub = sinon.stub().returns(fakeGotResponse);
419
- gotStub.extend = sinon.stub().returns(gotStub);
420
-
421
- const { submitBom } = await esmock("./index.js", {
422
- got: { default: gotStub },
423
- });
424
-
425
980
  const response = await submitBom(
426
981
  {
427
982
  serverUrl: "https://dtrack.example.com",
@@ -434,42 +989,57 @@ describe("CLI tests", () => {
434
989
  );
435
990
 
436
991
  assert.equal(response, undefined);
437
- sinon.assert.notCalled(gotStub);
438
992
  });
439
993
 
440
- it("disables redirects for the POST fallback request too", async () => {
441
- const putError = new Error("Method not allowed");
442
- putError.response = { statusCode: 405 };
443
- const gotStub = sinon.stub();
444
- gotStub
445
- .onFirstCall()
446
- .returns({
447
- json: sinon.stub().rejects(putError),
448
- })
449
- .onSecondCall()
450
- .returns({
451
- json: sinon.stub().resolves({ success: true }),
452
- });
453
- gotStub.extend = sinon.stub().returns(gotStub);
454
-
455
- const { submitBom } = await esmock("./index.js", {
456
- got: { default: gotStub },
457
- });
458
-
459
- await submitBom(
994
+ it("rejects malformed Dependency-Track URLs before making a request", async () => {
995
+ const response = await submitBom(
460
996
  {
461
- serverUrl: "https://dtrack.example.com",
997
+ serverUrl: "file:///tmp/dtrack",
462
998
  projectName: "cdxgen-test-project",
463
- apiKey: "TEST_API_KEY\r\n",
999
+ apiKey: "TEST_API_KEY",
464
1000
  },
465
- { bom: "test6" },
1001
+ { bom: "test-invalid-url" },
466
1002
  );
467
1003
 
468
- assert.equal(gotStub.callCount, 2);
469
- const [, postOptions] = gotStub.secondCall.args;
470
- assert.equal(postOptions.method, "POST");
471
- assert.equal(postOptions.followRedirect, false);
472
- assert.equal(postOptions.headers["X-Api-Key"], "TEST_API_KEY");
1004
+ assert.equal(response, undefined);
1005
+ });
1006
+
1007
+ it("disables redirects for the POST fallback request too", async () => {
1008
+ const server = await startSubmitBomTestServer(
1009
+ async (_request, requestCount) => {
1010
+ if (requestCount === 1) {
1011
+ return { body: { error: "Method not allowed" }, statusCode: 405 };
1012
+ }
1013
+ return { body: { success: true }, statusCode: 200 };
1014
+ },
1015
+ );
1016
+
1017
+ try {
1018
+ const response = await submitBom(
1019
+ {
1020
+ serverUrl: server.serverUrl,
1021
+ projectName: "cdxgen-test-project",
1022
+ apiKey: "TEST_API_KEY\r\n",
1023
+ },
1024
+ { bom: "test6" },
1025
+ );
1026
+
1027
+ assert.deepEqual(response, { success: true });
1028
+ assert.equal(server.requests.length, 2);
1029
+ assert.equal(server.requests[0].method, "PUT");
1030
+ assert.equal(server.requests[1].method, "POST");
1031
+ assert.equal(server.requests[1].url, "/api/v1/bom");
1032
+ assert.equal(
1033
+ getRequestHeader(server.requests[1], "x-api-key"),
1034
+ "TEST_API_KEY",
1035
+ );
1036
+ assert.equal(
1037
+ getRequestHeader(server.requests[1], "content-type"),
1038
+ "application/json",
1039
+ );
1040
+ } finally {
1041
+ await server.close();
1042
+ }
473
1043
  });
474
1044
  });
475
1045
 
@@ -654,6 +1224,136 @@ describe("CLI tests", () => {
654
1224
  rmSync(tempRoot, { recursive: true, force: true });
655
1225
  }
656
1226
  });
1227
+
1228
+ it("should not scan installed browser locations without explicit extension project type", async () => {
1229
+ const discoverChromiumExtensionDirs = sinon.stub().returns([
1230
+ {
1231
+ browser: "Google Chrome",
1232
+ channel: "stable",
1233
+ dir: join(tmpdir(), "fake-browser-dir"),
1234
+ },
1235
+ ]);
1236
+ const collectInstalledChromeExtensions = sinon.stub().returns([
1237
+ {
1238
+ type: "application",
1239
+ name: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1240
+ version: "1.0.0",
1241
+ purl: "pkg:chrome-extension/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@1.0.0",
1242
+ "bom-ref":
1243
+ "pkg:chrome-extension/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@1.0.0",
1244
+ },
1245
+ ]);
1246
+ const { createChromeExtensionBom: createChromeExtensionBomMocked } =
1247
+ await esmock("./index.js", {
1248
+ "../helpers/chromextutils.js": {
1249
+ CHROME_EXTENSION_PURL_TYPE: "chrome-extension",
1250
+ collectChromeExtensionsFromPath: sinon
1251
+ .stub()
1252
+ .returns({ components: [], extensionDirs: [] }),
1253
+ collectInstalledChromeExtensions,
1254
+ discoverChromiumExtensionDirs,
1255
+ },
1256
+ });
1257
+ const bomData = await createChromeExtensionBomMocked(
1258
+ join(tmpdir(), "generic-project"),
1259
+ {
1260
+ deep: true,
1261
+ multiProject: false,
1262
+ projectType: ["js"],
1263
+ },
1264
+ );
1265
+ assert.deepStrictEqual(bomData?.bomJson?.components || [], []);
1266
+ sinon.assert.notCalled(discoverChromiumExtensionDirs);
1267
+ sinon.assert.notCalled(collectInstalledChromeExtensions);
1268
+ });
1269
+ });
1270
+
1271
+ describe("createVscodeExtensionBom()", () => {
1272
+ it("should not scan installed IDE locations without explicit extension project type", async () => {
1273
+ const discoverIdeExtensionDirs = sinon.stub().returns([
1274
+ {
1275
+ name: "VS Code",
1276
+ dir: join(tmpdir(), "fake-ide-dir"),
1277
+ },
1278
+ ]);
1279
+ const collectInstalledExtensions = sinon.stub().returns([
1280
+ {
1281
+ type: "application",
1282
+ name: "sample.publisher",
1283
+ version: "1.0.0",
1284
+ purl: "pkg:vscode-extension/sample/publisher@1.0.0",
1285
+ "bom-ref": "pkg:vscode-extension/sample/publisher@1.0.0",
1286
+ },
1287
+ ]);
1288
+ const { createVscodeExtensionBom: createVscodeExtensionBomMocked } =
1289
+ await esmock("./index.js", {
1290
+ "../helpers/vsixutils.js": {
1291
+ cleanupTempDir: sinon.stub(),
1292
+ collectInstalledExtensions,
1293
+ discoverIdeExtensionDirs,
1294
+ extractVsixToTempDir: sinon.stub(),
1295
+ parseVsixFile: sinon.stub(),
1296
+ VSCODE_EXTENSION_PURL_TYPE: "vscode-extension",
1297
+ },
1298
+ });
1299
+ const bomData = await createVscodeExtensionBomMocked(
1300
+ join(tmpdir(), "generic-project"),
1301
+ {
1302
+ deep: true,
1303
+ multiProject: false,
1304
+ projectType: ["js"],
1305
+ },
1306
+ );
1307
+ assert.deepStrictEqual(bomData?.bomJson?.components || [], []);
1308
+ sinon.assert.notCalled(discoverIdeExtensionDirs);
1309
+ sinon.assert.notCalled(collectInstalledExtensions);
1310
+ });
1311
+
1312
+ it("should scan installed IDE locations when explicitly requested", async () => {
1313
+ const discoverIdeExtensionDirs = sinon.stub().returns([
1314
+ {
1315
+ name: "VS Code",
1316
+ dir: join(tmpdir(), "fake-ide-dir"),
1317
+ },
1318
+ ]);
1319
+ const collectInstalledExtensions = sinon.stub().returns([
1320
+ {
1321
+ type: "application",
1322
+ name: "sample.publisher",
1323
+ version: "1.0.0",
1324
+ purl: "pkg:vscode-extension/sample/publisher@1.0.0",
1325
+ "bom-ref": "pkg:vscode-extension/sample/publisher@1.0.0",
1326
+ },
1327
+ ]);
1328
+ const { createVscodeExtensionBom: createVscodeExtensionBomMocked } =
1329
+ await esmock("./index.js", {
1330
+ "../helpers/vsixutils.js": {
1331
+ cleanupTempDir: sinon.stub(),
1332
+ collectInstalledExtensions,
1333
+ discoverIdeExtensionDirs,
1334
+ extractVsixToTempDir: sinon.stub(),
1335
+ parseVsixFile: sinon.stub(),
1336
+ VSCODE_EXTENSION_PURL_TYPE: "vscode-extension",
1337
+ },
1338
+ });
1339
+ const bomData = await createVscodeExtensionBomMocked(
1340
+ join(tmpdir(), "generic-project"),
1341
+ {
1342
+ deep: true,
1343
+ multiProject: false,
1344
+ projectType: ["ide-extension"],
1345
+ },
1346
+ );
1347
+ const components = bomData?.bomJson?.components || [];
1348
+ assert.ok(
1349
+ components.some(
1350
+ (component) =>
1351
+ component.purl === "pkg:vscode-extension/sample/publisher@1.0.0",
1352
+ ),
1353
+ );
1354
+ sinon.assert.calledOnce(discoverIdeExtensionDirs);
1355
+ sinon.assert.calledOnce(collectInstalledExtensions);
1356
+ });
657
1357
  });
658
1358
 
659
1359
  describe("createMultiXBom()", () => {
@@ -1088,6 +1788,291 @@ checksum = "${"a".repeat(64)}"
1088
1788
  });
1089
1789
  });
1090
1790
 
1791
+ if (process.platform !== "win32") {
1792
+ describe("HBOM support", () => {
1793
+ it("delegates hbom project types to the hbom helper", async () => {
1794
+ const actualHbomHelpers = await import("../helpers/hbom.js");
1795
+ const createHbomDocument = sinon.stub().resolves({
1796
+ bomFormat: "CycloneDX",
1797
+ components: [],
1798
+ metadata: {
1799
+ component: {
1800
+ name: "Demo Board",
1801
+ type: "device",
1802
+ version: "rev-a",
1803
+ },
1804
+ },
1805
+ specVersion: "1.7",
1806
+ });
1807
+ const { createBom: createBomMocked } = await esmock("./index.js", {
1808
+ "../helpers/hbom.js": {
1809
+ ...actualHbomHelpers,
1810
+ createHbomDocument,
1811
+ },
1812
+ });
1813
+
1814
+ const bomNSData = await createBomMocked(repoDir, {
1815
+ projectType: ["hbom"],
1816
+ specVersion: 1.7,
1817
+ });
1818
+
1819
+ sinon.assert.calledOnce(createHbomDocument);
1820
+ assert.strictEqual(
1821
+ bomNSData?.bomJson?.metadata?.component?.name,
1822
+ "Demo Board",
1823
+ );
1824
+ assert.strictEqual(bomNSData?.parentComponent?.type, "device");
1825
+ });
1826
+
1827
+ it("supports dry-run mode for hbom project types in the main CLI flow", async () => {
1828
+ setDryRunMode(true);
1829
+ resetRecordedActivities();
1830
+
1831
+ try {
1832
+ const bomNSData = await createBom(repoDir, {
1833
+ projectType: ["hbom"],
1834
+ specVersion: 1.7,
1835
+ });
1836
+
1837
+ assert.strictEqual(bomNSData?.bomJson?.bomFormat, "CycloneDX");
1838
+ assert.strictEqual(bomNSData?.bomJson?.specVersion, "1.7");
1839
+ assert.ok(Array.isArray(bomNSData?.bomJson?.components));
1840
+ assert.ok(bomNSData?.bomJson?.components.length >= 1);
1841
+ assert.ok(Array.isArray(bomNSData?.dependencies));
1842
+ } finally {
1843
+ setDryRunMode(false);
1844
+ resetRecordedActivities();
1845
+ }
1846
+ });
1847
+
1848
+ it("shows dedicated hbom command help", () => {
1849
+ const result = spawnSync(
1850
+ process.execPath,
1851
+ [join(repoDir, "bin", "hbom.js"), "--help"],
1852
+ {
1853
+ cwd: repoDir,
1854
+ encoding: "utf8",
1855
+ env: buildMinimalCliEnv(),
1856
+ },
1857
+ );
1858
+ const output = `${result.stdout}${result.stderr}`;
1859
+
1860
+ assert.strictEqual(result.status, 0);
1861
+ assert.match(output, /Output file\.\s+Default\s+hbom\.json/u);
1862
+ assert.match(output, /--include-runtime/u);
1863
+ assert.match(output, /--privileged/u);
1864
+ assert.match(output, /diagnostics/u);
1865
+ });
1866
+
1867
+ it("uses the invoked hbom binary name in help output", () => {
1868
+ const tempDir = mkdtempSync(join(repoDir, ".cdxgen-hbom-help-name-"));
1869
+ try {
1870
+ const slimScript = join(tempDir, "hbom-slim");
1871
+ copyFileSync(join(repoDir, "bin", "hbom.js"), slimScript);
1872
+ const result = spawnSync(process.execPath, [slimScript, "--help"], {
1873
+ cwd: tempDir,
1874
+ encoding: "utf8",
1875
+ env: buildMinimalCliEnv(),
1876
+ });
1877
+ const output = `${result.stdout}${result.stderr}`;
1878
+
1879
+ assert.strictEqual(result.status, 0);
1880
+ assert.match(output, /hbom-slim \[command\] \[options\]/u);
1881
+ } finally {
1882
+ rmSync(tempDir, { force: true, recursive: true });
1883
+ }
1884
+ });
1885
+
1886
+ it("fails early when hbom include-runtime lacks osquery support", () => {
1887
+ const emptyPluginsDir = mkdtempSync(
1888
+ join(tmpdir(), "cdxgen-empty-plugins-"),
1889
+ );
1890
+ try {
1891
+ const result = spawnSync(
1892
+ process.execPath,
1893
+ [join(repoDir, "bin", "hbom.js"), "--include-runtime"],
1894
+ {
1895
+ cwd: repoDir,
1896
+ encoding: "utf8",
1897
+ env: buildMinimalCliEnv({
1898
+ CDXGEN_PLUGINS_DIR: emptyPluginsDir,
1899
+ }),
1900
+ },
1901
+ );
1902
+ const output = `${result.stdout}${result.stderr}`;
1903
+
1904
+ assert.strictEqual(result.status, 1);
1905
+ assert.match(output, /--include-runtime/u);
1906
+ assert.match(output, /cdxgen-plugins-bin/u);
1907
+ assert.match(
1908
+ output,
1909
+ /'hbom' is the bundled option required for '--include-runtime' support/u,
1910
+ );
1911
+ assert.doesNotMatch(output, /About to generate OBOM/u);
1912
+ } finally {
1913
+ rmSync(emptyPluginsDir, { force: true, recursive: true });
1914
+ }
1915
+ });
1916
+
1917
+ it("guides hbom-slim users to the standard binary for include-runtime", () => {
1918
+ const tempDir = mkdtempSync(
1919
+ join(repoDir, ".cdxgen-hbom-runtime-check-"),
1920
+ );
1921
+ const emptyPluginsDir = mkdtempSync(
1922
+ join(tmpdir(), "cdxgen-empty-plugins-"),
1923
+ );
1924
+ try {
1925
+ const slimScript = join(tempDir, "hbom-slim");
1926
+ copyFileSync(join(repoDir, "bin", "hbom.js"), slimScript);
1927
+ const result = spawnSync(
1928
+ process.execPath,
1929
+ [slimScript, "--include-runtime"],
1930
+ {
1931
+ cwd: tempDir,
1932
+ encoding: "utf8",
1933
+ env: buildMinimalCliEnv({
1934
+ CDXGEN_PLUGINS_DIR: emptyPluginsDir,
1935
+ }),
1936
+ },
1937
+ );
1938
+ const output = `${result.stdout}${result.stderr}`;
1939
+
1940
+ assert.strictEqual(result.status, 1);
1941
+ assert.match(output, /'hbom-slim' is hardware-only by default/u);
1942
+ assert.match(
1943
+ output,
1944
+ /Use 'hbom' for bundled '--include-runtime' support/u,
1945
+ );
1946
+ } finally {
1947
+ rmSync(tempDir, { force: true, recursive: true });
1948
+ rmSync(emptyPluginsDir, { force: true, recursive: true });
1949
+ }
1950
+ });
1951
+
1952
+ it("supports the hbom diagnostics subcommand for existing BOM files", () => {
1953
+ const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-hbom-diagnostics-"));
1954
+ try {
1955
+ const inputFile = join(tempDir, "hbom.json");
1956
+ writeFileSync(
1957
+ inputFile,
1958
+ JSON.stringify({
1959
+ bomFormat: "CycloneDX",
1960
+ components: [],
1961
+ metadata: {
1962
+ component: {
1963
+ name: "demo-host",
1964
+ properties: [
1965
+ { name: "cdx:hbom:platform", value: "linux" },
1966
+ { name: "cdx:hbom:architecture", value: "amd64" },
1967
+ ],
1968
+ type: "device",
1969
+ },
1970
+ },
1971
+ properties: [
1972
+ { name: "cdx:hbom:collectorProfile", value: "linux-amd64-v1" },
1973
+ {
1974
+ name: "cdx:hbom:evidence:commandDiagnosticCount",
1975
+ value: "2",
1976
+ },
1977
+ {
1978
+ name: "cdx:hbom:evidence:commandDiagnostic",
1979
+ value: JSON.stringify({
1980
+ command: "lsusb",
1981
+ installHint:
1982
+ "Command not found: install the Linux package providing lsusb (commonly `usbutils`).",
1983
+ issue: "missing-command",
1984
+ message: "lsusb failed with missing-command",
1985
+ }),
1986
+ },
1987
+ {
1988
+ name: "cdx:hbom:evidence:commandDiagnostic",
1989
+ value: JSON.stringify({
1990
+ command: "drm_info",
1991
+ issue: "permission-denied",
1992
+ message: "drm_info failed with permission-denied",
1993
+ privilegeHint:
1994
+ "Retry with --privileged to allow a non-interactive sudo attempt for permission-sensitive Linux commands.",
1995
+ }),
1996
+ },
1997
+ ],
1998
+ specVersion: "1.7",
1999
+ version: 1,
2000
+ }),
2001
+ );
2002
+ const result = spawnSync(
2003
+ process.execPath,
2004
+ [
2005
+ join(repoDir, "bin", "hbom.js"),
2006
+ "diagnostics",
2007
+ "--input",
2008
+ inputFile,
2009
+ ],
2010
+ {
2011
+ cwd: tempDir,
2012
+ encoding: "utf8",
2013
+ env: buildMinimalCliEnv(),
2014
+ },
2015
+ );
2016
+ const output = `${result.stdout}${result.stderr}`;
2017
+
2018
+ assert.strictEqual(result.status, 0);
2019
+ assert.match(output, /HBOM diagnostics summary/u);
2020
+ assert.match(output, /Missing commands:\n- lsusb/u);
2021
+ assert.match(output, /Permission-sensitive enrichments:/u);
2022
+ assert.match(output, /--privileged/u);
2023
+ } finally {
2024
+ rmSync(tempDir, { force: true, recursive: true });
2025
+ }
2026
+ });
2027
+
2028
+ it("supports dry-run mode in the dedicated hbom command", () => {
2029
+ const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-hbom-dry-run-"));
2030
+ try {
2031
+ const outputFile = join(tempDir, "hbom.json");
2032
+ const result = spawnSync(
2033
+ process.execPath,
2034
+ [join(repoDir, "bin", "hbom.js"), "--dry-run"],
2035
+ {
2036
+ cwd: tempDir,
2037
+ encoding: "utf8",
2038
+ env: buildMinimalCliEnv(),
2039
+ },
2040
+ );
2041
+ const output = `${result.stdout}${result.stderr}`;
2042
+
2043
+ assert.strictEqual(result.status, 0);
2044
+ assert.match(output, /cdxgen dry-run activity summary/u);
2045
+ assert.strictEqual(existsSync(outputFile), false);
2046
+ } finally {
2047
+ rmSync(tempDir, { force: true, recursive: true });
2048
+ }
2049
+ });
2050
+
2051
+ it("rejects mixed hbom and sbom project types in the main CLI", () => {
2052
+ const result = spawnSync(
2053
+ process.execPath,
2054
+ [
2055
+ join(repoDir, "bin", "cdxgen.js"),
2056
+ "-t",
2057
+ "hbom",
2058
+ "-t",
2059
+ "js",
2060
+ "--no-banner",
2061
+ ],
2062
+ {
2063
+ cwd: repoDir,
2064
+ encoding: "utf8",
2065
+ env: buildMinimalCliEnv(),
2066
+ },
2067
+ );
2068
+ const output = `${result.stdout}${result.stderr}`;
2069
+
2070
+ assert.strictEqual(result.status, 1);
2071
+ assert.match(output, /HBOM project types cannot be mixed/u);
2072
+ });
2073
+ });
2074
+ }
2075
+
1091
2076
  describe("createBom() Collider lock support", () => {
1092
2077
  it("preserves Collider integrity metadata and dependency nodes in the BOM", async () => {
1093
2078
  const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-collider-"));
@@ -1421,7 +2406,7 @@ checksum = "${"a".repeat(64)}"
1421
2406
  );
1422
2407
  });
1423
2408
 
1424
- it("supports exact AI skill scans and js exclude-type filtering for AI inventory", async () => {
2409
+ it("requires explicit opt-in for AI inventory in js and python scans", async () => {
1425
2410
  const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-ai-inventory-"));
1426
2411
  mkdirSync(join(tmpDir, ".claude", "skills", "release"), {
1427
2412
  recursive: true,
@@ -1539,88 +2524,158 @@ checksum = "${"a".repeat(64)}"
1539
2524
  tmpDir,
1540
2525
  ).bomJson;
1541
2526
  assert.ok(
1542
- (jsBomJson.components || []).some(
1543
- (component) =>
1544
- getProp(component, "cdx:file:kind") === "skill-file" &&
1545
- getProp(component, "cdx:skill:name") === "release",
2527
+ !(jsBomJson.components || []).some((component) =>
2528
+ ["agent-instructions", "mcp-config", "skill-file"].includes(
2529
+ getProp(component, "cdx:file:kind"),
2530
+ ),
2531
+ ),
2532
+ "did not expect AI inventory components in js scan without opt-in",
2533
+ );
2534
+ assert.ok(
2535
+ !(jsBomJson.services || []).some((service) =>
2536
+ service.properties?.some((property) =>
2537
+ property.name.startsWith("cdx:mcp:"),
2538
+ ),
1546
2539
  ),
1547
- "expected skill file in js scan",
2540
+ "did not expect MCP services in js scan without opt-in",
1548
2541
  );
2542
+
2543
+ const dockerOptions = {
2544
+ ...baseOptions,
2545
+ projectType: ["js", "docker"],
2546
+ };
2547
+ const dockerBomJson = postProcess(
2548
+ await createNodejsBom(tmpDir, dockerOptions),
2549
+ dockerOptions,
2550
+ tmpDir,
2551
+ ).bomJson;
2552
+ assert.ok(
2553
+ !(dockerBomJson.components || []).some((component) =>
2554
+ ["agent-instructions", "mcp-config", "skill-file"].includes(
2555
+ getProp(component, "cdx:file:kind"),
2556
+ ),
2557
+ ),
2558
+ "did not expect AI inventory components in docker js scan without opt-in",
2559
+ );
2560
+
2561
+ const exactAiSkillOptions = {
2562
+ ...baseOptions,
2563
+ projectType: ["ai-skill"],
2564
+ };
2565
+ const aiSkillBomJson = postProcess(
2566
+ await createBom(tmpDir, exactAiSkillOptions),
2567
+ exactAiSkillOptions,
2568
+ tmpDir,
2569
+ ).bomJson;
1549
2570
  assert.ok(
1550
- (jsBomJson.components || []).some(
2571
+ (aiSkillBomJson.components || []).some(
1551
2572
  (component) =>
1552
2573
  component.name === "CLAUDE.md" &&
1553
2574
  getProp(component, "cdx:file:kind") === "agent-instructions",
1554
2575
  ),
1555
- "expected CLAUDE.md in js scan",
2576
+ "expected CLAUDE.md in exact ai-skill scan",
2577
+ );
2578
+ assert.ok(
2579
+ !(aiSkillBomJson.components || []).some(
2580
+ (component) => getProp(component, "cdx:file:kind") === "mcp-config",
2581
+ ),
2582
+ "did not expect MCP configs in exact ai-skill scan",
2583
+ );
2584
+
2585
+ const optedInJsOptions = {
2586
+ ...baseOptions,
2587
+ projectType: ["js", "ai-skill", "mcp"],
2588
+ };
2589
+ const optedInJsBomJson = postProcess(
2590
+ await createBom(tmpDir, optedInJsOptions),
2591
+ optedInJsOptions,
2592
+ tmpDir,
2593
+ ).bomJson;
2594
+ assert.ok(
2595
+ (optedInJsBomJson.components || []).some(
2596
+ (component) =>
2597
+ getProp(component, "cdx:file:kind") === "skill-file" &&
2598
+ getProp(component, "cdx:skill:name") === "release",
2599
+ ),
2600
+ "expected skill file in opted-in js scan",
1556
2601
  );
1557
2602
  assert.ok(
1558
- (jsBomJson.components || []).some(
2603
+ (optedInJsBomJson.components || []).some(
1559
2604
  (component) => getProp(component, "cdx:file:kind") === "mcp-config",
1560
2605
  ),
1561
- "expected MCP config in js scan",
2606
+ "expected MCP config in opted-in js scan",
1562
2607
  );
1563
2608
  assert.ok(
1564
- (jsBomJson.services || []).some(
2609
+ (optedInJsBomJson.services || []).some(
1565
2610
  (service) =>
1566
2611
  service.name === "releaseDocs" &&
1567
2612
  getProp(service, "cdx:mcp:inventorySource") === "config-file",
1568
2613
  ),
1569
- "expected MCP config service in js scan",
2614
+ "expected MCP config service in opted-in js scan",
1570
2615
  );
1571
2616
 
1572
- const dockerOptions = {
2617
+ const auditAliasJsOptions = {
1573
2618
  ...baseOptions,
1574
- projectType: ["js", "docker"],
2619
+ bomAuditCategories: "ai-inventory",
2620
+ projectType: ["js"],
1575
2621
  };
1576
- const dockerBomJson = postProcess(
1577
- await createNodejsBom(tmpDir, dockerOptions),
1578
- dockerOptions,
2622
+ const auditAliasJsBomJson = postProcess(
2623
+ await createBom(tmpDir, auditAliasJsOptions),
2624
+ auditAliasJsOptions,
1579
2625
  tmpDir,
1580
2626
  ).bomJson;
1581
2627
  assert.ok(
1582
- (dockerBomJson.components || []).some(
2628
+ (auditAliasJsBomJson.components || []).some(
1583
2629
  (component) =>
1584
2630
  getProp(component, "cdx:file:kind") === "skill-file" &&
1585
2631
  getProp(component, "cdx:skill:name") === "release",
1586
2632
  ),
1587
- "expected skill file in docker js scan",
2633
+ "expected skill file in ai-inventory audit-category js scan",
1588
2634
  );
1589
2635
  assert.ok(
1590
- (dockerBomJson.components || []).some(
2636
+ (auditAliasJsBomJson.components || []).some(
1591
2637
  (component) => getProp(component, "cdx:file:kind") === "mcp-config",
1592
2638
  ),
1593
- "expected MCP config in docker js scan",
2639
+ "expected MCP config in ai-inventory audit-category js scan",
2640
+ );
2641
+ assert.ok(
2642
+ (auditAliasJsBomJson.services || []).some(
2643
+ (service) =>
2644
+ service.name === "releaseDocs" &&
2645
+ getProp(service, "cdx:mcp:inventorySource") === "config-file",
2646
+ ),
2647
+ "expected MCP config service in ai-inventory audit-category js scan",
1594
2648
  );
1595
2649
 
1596
- const exactAiSkillOptions = {
2650
+ const auditAgentJsOptions = {
1597
2651
  ...baseOptions,
1598
- projectType: ["ai-skill"],
2652
+ bomAuditCategories: "ai-agent",
2653
+ projectType: ["js"],
1599
2654
  };
1600
- const aiSkillBomJson = postProcess(
1601
- await createBom(tmpDir, exactAiSkillOptions),
1602
- exactAiSkillOptions,
2655
+ const auditAgentJsBomJson = postProcess(
2656
+ await createBom(tmpDir, auditAgentJsOptions),
2657
+ auditAgentJsOptions,
1603
2658
  tmpDir,
1604
2659
  ).bomJson;
1605
2660
  assert.ok(
1606
- (aiSkillBomJson.components || []).some(
2661
+ (auditAgentJsBomJson.components || []).some(
1607
2662
  (component) =>
1608
- component.name === "CLAUDE.md" &&
1609
- getProp(component, "cdx:file:kind") === "agent-instructions",
2663
+ getProp(component, "cdx:file:kind") === "skill-file" &&
2664
+ getProp(component, "cdx:skill:name") === "release",
1610
2665
  ),
1611
- "expected CLAUDE.md in exact ai-skill scan",
2666
+ "expected skill file in ai-agent audit-category js scan",
1612
2667
  );
1613
2668
  assert.ok(
1614
- !(aiSkillBomJson.components || []).some(
2669
+ !(auditAgentJsBomJson.components || []).some(
1615
2670
  (component) => getProp(component, "cdx:file:kind") === "mcp-config",
1616
2671
  ),
1617
- "did not expect MCP configs in exact ai-skill scan",
2672
+ "did not expect MCP config in ai-agent audit-category js scan",
1618
2673
  );
1619
2674
 
1620
2675
  const filteredOptions = {
1621
2676
  ...baseOptions,
1622
2677
  excludeType: ["ai-skill", "mcp"],
1623
- projectType: ["js"],
2678
+ projectType: ["js", "ai-skill", "mcp"],
1624
2679
  };
1625
2680
  const filteredBomJson = postProcess(
1626
2681
  await createBom(tmpDir, filteredOptions),
@@ -1654,29 +2709,114 @@ checksum = "${"a".repeat(64)}"
1654
2709
  tmpDir,
1655
2710
  ).bomJson;
1656
2711
  assert.ok(
1657
- (pyBomJson.components || []).some(
2712
+ !(pyBomJson.components || []).some((component) =>
2713
+ ["agent-instructions", "mcp-config", "skill-file"].includes(
2714
+ getProp(component, "cdx:file:kind"),
2715
+ ),
2716
+ ),
2717
+ "did not expect AI inventory components in python scan without opt-in",
2718
+ );
2719
+ assert.ok(
2720
+ !(pyBomJson.services || []).some((service) =>
2721
+ service.properties?.some((property) =>
2722
+ property.name.startsWith("cdx:mcp:"),
2723
+ ),
2724
+ ),
2725
+ "did not expect MCP services in python scan without opt-in",
2726
+ );
2727
+
2728
+ const optedInPyOptions = {
2729
+ ...baseOptions,
2730
+ projectType: ["py", "ai-skill", "mcp"],
2731
+ };
2732
+ const optedInPyBomJson = postProcess(
2733
+ await createPythonBom(tmpDir, optedInPyOptions),
2734
+ optedInPyOptions,
2735
+ tmpDir,
2736
+ ).bomJson;
2737
+ assert.ok(
2738
+ (optedInPyBomJson.components || []).some(
1658
2739
  (component) =>
1659
2740
  getProp(component, "cdx:file:kind") === "skill-file" &&
1660
2741
  getProp(component, "cdx:skill:name") === "release",
1661
2742
  ),
1662
- "expected skill file in python scan",
2743
+ "expected skill file in opted-in python scan",
1663
2744
  );
1664
2745
  assert.ok(
1665
- (pyBomJson.components || []).some(
2746
+ (optedInPyBomJson.components || []).some(
1666
2747
  (component) => getProp(component, "cdx:file:kind") === "mcp-config",
1667
2748
  ),
1668
- "expected MCP config in python scan",
2749
+ "expected MCP config in opted-in python scan",
1669
2750
  );
1670
2751
  assert.ok(
1671
- (pyBomJson.services || []).some(
2752
+ (optedInPyBomJson.services || []).some(
1672
2753
  (service) =>
1673
2754
  service.name === "python-release-docs" &&
1674
2755
  getProp(service, "cdx:mcp:inventorySource") ===
1675
2756
  "source-code-analysis",
1676
2757
  ),
1677
- "expected Python MCP service in python scan",
2758
+ "expected Python MCP service in opted-in python scan",
2759
+ );
2760
+
2761
+ const auditMcpPyOptions = {
2762
+ ...baseOptions,
2763
+ bomAuditCategories: "mcp-server",
2764
+ projectType: ["py"],
2765
+ };
2766
+ const auditMcpPyBomJson = postProcess(
2767
+ await createPythonBom(tmpDir, auditMcpPyOptions),
2768
+ auditMcpPyOptions,
2769
+ tmpDir,
2770
+ ).bomJson;
2771
+ assert.ok(
2772
+ (auditMcpPyBomJson.services || []).some(
2773
+ (service) =>
2774
+ service.name === "python-release-docs" &&
2775
+ getProp(service, "cdx:mcp:inventorySource") ===
2776
+ "source-code-analysis",
2777
+ ),
2778
+ "expected Python MCP service in mcp-server audit-category scan",
2779
+ );
2780
+ assert.ok(
2781
+ !(auditMcpPyBomJson.components || []).some(
2782
+ (component) => getProp(component, "cdx:file:kind") === "skill-file",
2783
+ ),
2784
+ "did not expect skill file in mcp-server audit-category python scan",
2785
+ );
2786
+ } finally {
2787
+ rmSync(tmpDir, { force: true, recursive: true });
2788
+ }
2789
+ });
2790
+
2791
+ it("does not trace an npm registry config read when opening .npmrc fails", async () => {
2792
+ const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-npmrc-read-fail-"));
2793
+ writeFileSync(
2794
+ join(tmpDir, "package.json"),
2795
+ JSON.stringify({
2796
+ name: "npmrc-read-fail",
2797
+ version: "1.0.0",
2798
+ }),
2799
+ );
2800
+ mkdirSync(join(tmpDir, ".npmrc"), { recursive: true });
2801
+ setDryRunMode(true);
2802
+ resetRecordedActivities();
2803
+ try {
2804
+ await assert.rejects(() =>
2805
+ createNodejsBom(tmpDir, {
2806
+ installDeps: true,
2807
+ multiProject: false,
2808
+ projectType: ["npm"],
2809
+ }),
2810
+ );
2811
+ const readActivities = getRecordedActivities().filter(
2812
+ (activity) =>
2813
+ activity.kind === "read" &&
2814
+ activity.target === join(tmpDir, ".npmrc"),
1678
2815
  );
2816
+ assert.deepStrictEqual(readActivities, []);
1679
2817
  } finally {
2818
+ setDryRunMode(false);
2819
+ resetRecordedActivities();
1680
2820
  rmSync(tmpDir, { force: true, recursive: true });
1681
2821
  }
1682
2822
  });