@cyclonedx/cdxgen 12.3.2 → 12.3.3

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 (53) hide show
  1. package/README.md +6 -0
  2. package/data/rules/ci-permissions.yaml +132 -0
  3. package/data/rules/dependency-sources.yaml +65 -5
  4. package/data/rules/package-integrity.yaml +22 -0
  5. package/lib/cli/index.js +141 -39
  6. package/lib/cli/index.poku.js +579 -1
  7. package/lib/helpers/agentFormulationParser.js +6 -2
  8. package/lib/helpers/agentFormulationParser.poku.js +42 -0
  9. package/lib/helpers/analyzer.js +38 -9
  10. package/lib/helpers/analyzer.poku.js +67 -0
  11. package/lib/helpers/chromextutils.js +25 -3
  12. package/lib/helpers/chromextutils.poku.js +68 -0
  13. package/lib/helpers/ciParsers/githubActions.js +79 -0
  14. package/lib/helpers/ciParsers/githubActions.poku.js +103 -0
  15. package/lib/helpers/communityAiConfigParser.js +15 -5
  16. package/lib/helpers/communityAiConfigParser.poku.js +71 -0
  17. package/lib/helpers/depsUtils.js +5 -0
  18. package/lib/helpers/depsUtils.poku.js +55 -0
  19. package/lib/helpers/display.js +45 -22
  20. package/lib/helpers/display.poku.js +47 -60
  21. package/lib/helpers/mcpConfigParser.js +21 -5
  22. package/lib/helpers/mcpConfigParser.poku.js +39 -2
  23. package/lib/helpers/propertySanitizer.js +121 -0
  24. package/lib/helpers/utils.js +951 -40
  25. package/lib/helpers/utils.poku.js +882 -0
  26. package/lib/managers/binary.js +16 -0
  27. package/lib/managers/binary.poku.js +1 -0
  28. package/lib/managers/docker.js +240 -16
  29. package/lib/managers/docker.poku.js +1142 -2
  30. package/lib/server/server.js +7 -4
  31. package/lib/server/server.poku.js +36 -1
  32. package/lib/stages/postgen/auditBom.poku.js +644 -2
  33. package/package.json +2 -1
  34. package/types/lib/cli/index.d.ts.map +1 -1
  35. package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -1
  36. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  37. package/types/lib/helpers/chromextutils.d.ts.map +1 -1
  38. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  39. package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -1
  40. package/types/lib/helpers/depsUtils.d.ts.map +1 -1
  41. package/types/lib/helpers/display.d.ts +1 -0
  42. package/types/lib/helpers/display.d.ts.map +1 -1
  43. package/types/lib/helpers/mcpConfigParser.d.ts +1 -1
  44. package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -1
  45. package/types/lib/helpers/propertySanitizer.d.ts +3 -0
  46. package/types/lib/helpers/propertySanitizer.d.ts.map +1 -0
  47. package/types/lib/helpers/utils.d.ts +29 -0
  48. package/types/lib/helpers/utils.d.ts.map +1 -1
  49. package/types/lib/managers/binary.d.ts.map +1 -1
  50. package/types/lib/managers/docker.d.ts +3 -0
  51. package/types/lib/managers/docker.d.ts.map +1 -1
  52. package/types/lib/server/server.d.ts +1 -0
  53. package/types/lib/server/server.d.ts.map +1 -1
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import {
2
3
  mkdirSync,
3
4
  mkdtempSync,
@@ -6,7 +7,7 @@ import {
6
7
  writeFileSync,
7
8
  } from "node:fs";
8
9
  import { tmpdir } from "node:os";
9
- import { dirname, join } from "node:path";
10
+ import { dirname, join, normalize, sep } from "node:path";
10
11
  import process from "node:process";
11
12
  import { fileURLToPath } from "node:url";
12
13
 
@@ -20,6 +21,7 @@ import {
20
21
  createBom,
21
22
  createChromeExtensionBom,
22
23
  createNodejsBom,
24
+ createPHPBom,
23
25
  createRustBom,
24
26
  } from "./index.js";
25
27
 
@@ -58,12 +60,115 @@ const mcpFixtureDir = join(
58
60
  "data",
59
61
  "mcp-repotest",
60
62
  );
63
+ const cacheDisableFixtureDir = join(
64
+ dirname(fileURLToPath(import.meta.url)),
65
+ "..",
66
+ "..",
67
+ "test",
68
+ "data",
69
+ "cache-disable-repotest",
70
+ );
71
+ const composerFixtureDir = join(
72
+ dirname(fileURLToPath(import.meta.url)),
73
+ "..",
74
+ "..",
75
+ "test",
76
+ "data",
77
+ );
78
+ const repoDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
61
79
 
62
80
  function getProp(obj, name) {
63
81
  return obj?.properties?.find((property) => property.name === name)?.value;
64
82
  }
65
83
 
84
+ function createComposerNodeModulesFixture() {
85
+ const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-composer-node-modules-"));
86
+ const packageDir = join(tmpDir, "node_modules", "moment-timezone");
87
+ mkdirSync(packageDir, { recursive: true });
88
+ writeFileSync(
89
+ join(packageDir, "composer.json"),
90
+ readFileSync(join(composerFixtureDir, "composer.json"), "utf-8"),
91
+ );
92
+ writeFileSync(
93
+ join(packageDir, "composer.lock"),
94
+ readFileSync(join(composerFixtureDir, "composer.lock"), "utf-8"),
95
+ );
96
+ return tmpDir;
97
+ }
98
+
99
+ function createJarNodeModulesFixture() {
100
+ const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-jar-node-modules-"));
101
+ const packageDir = join(tmpDir, "node_modules", "font-mfizz");
102
+ mkdirSync(packageDir, { recursive: true });
103
+ writeFileSync(join(packageDir, "blaze.jar"), "fake jar content");
104
+ return tmpDir;
105
+ }
106
+
107
+ const stubbedJarPackage = {
108
+ group: "org.slf4j",
109
+ name: "slf4j-simple",
110
+ version: "2.0.17",
111
+ purl: "pkg:maven/org.slf4j/slf4j-simple@2.0.17?type=jar",
112
+ "bom-ref": "pkg:maven/org.slf4j/slf4j-simple@2.0.17?type=jar",
113
+ };
114
+
115
+ async function loadStubbedCreateJarBom() {
116
+ const actualUtils = await import("../helpers/utils.js");
117
+ const extractJarArchive = sinon.stub().resolves([stubbedJarPackage]);
118
+ const getMvnMetadata = sinon.stub().callsFake(async (pkgList) => pkgList);
119
+ const mockedIndex = await esmock("./index.js", {
120
+ "../helpers/utils.js": {
121
+ ...actualUtils,
122
+ extractJarArchive,
123
+ getMvnMetadata,
124
+ },
125
+ });
126
+ return mockedIndex.createJarBom;
127
+ }
128
+
129
+ function toPortablePath(filePath) {
130
+ return normalize(filePath).split(sep).join("/");
131
+ }
132
+
133
+ function getNpmPackFilePaths() {
134
+ const command =
135
+ process.platform === "win32"
136
+ ? {
137
+ args: ["/c", "npm", "pack", "--dry-run", "--json"],
138
+ file: process.env.ComSpec || "cmd.exe",
139
+ }
140
+ : {
141
+ args: ["pack", "--dry-run", "--json"],
142
+ file: "npm",
143
+ };
144
+ const packOutput = execFileSync(command.file, command.args, {
145
+ cwd: repoDir,
146
+ encoding: "utf8",
147
+ });
148
+ const [packSummary] = JSON.parse(packOutput);
149
+ return packSummary.files.map((file) => toPortablePath(file.path));
150
+ }
151
+
66
152
  describe("CLI tests", () => {
153
+ describe("distribution filters", () => {
154
+ it("keeps npm types while excluding poku tests from npm pack output", () => {
155
+ const packedPaths = getNpmPackFilePaths();
156
+
157
+ assert.ok(
158
+ packedPaths.some((path) => path.startsWith("types/")),
159
+ "expected npm pack output to keep generated type definitions",
160
+ );
161
+ assert.ok(
162
+ packedPaths.every((path) => !path.endsWith(".poku.js")),
163
+ "expected npm pack output to exclude co-located poku tests",
164
+ );
165
+ assert.ok(
166
+ packedPaths.every((path) => !path.startsWith("test/")),
167
+ "expected npm pack output to exclude test fixtures",
168
+ );
169
+ });
170
+ });
171
+
67
172
  describe("submitBom()", () => {
68
173
  it("should report blocked Dependency-Track submission during dry-run", async () => {
69
174
  const recordActivity = sinon.stub();
@@ -146,6 +251,7 @@ describe("CLI tests", () => {
146
251
 
147
252
  assert.equal(calledUrl, `${serverUrl}/api/v1/bom`);
148
253
  assert.equal(options.method, "PUT");
254
+ assert.equal(options.followRedirect, false);
149
255
  assert.equal(options.https.rejectUnauthorized, !skipDtTlsCheck);
150
256
  assert.equal(options.headers["X-Api-Key"], apiKey);
151
257
  assert.match(options.headers["user-agent"], /@CycloneDX\/cdxgen/);
@@ -206,6 +312,7 @@ describe("CLI tests", () => {
206
312
  // Assert call arguments against expectations
207
313
  assert.equal(calledUrl, `${serverUrl}/api/v1/bom`);
208
314
  assert.equal(options.method, "PUT");
315
+ assert.equal(options.followRedirect, false);
209
316
  assert.equal(options.https.rejectUnauthorized, !skipDtTlsCheck);
210
317
  assert.equal(options.headers["X-Api-Key"], apiKey);
211
318
  assert.match(options.headers["user-agent"], /@CycloneDX\/cdxgen/);
@@ -262,6 +369,7 @@ describe("CLI tests", () => {
262
369
 
263
370
  assert.equal(calledUrl, `${serverUrl}/api/v1/bom`);
264
371
  assert.equal(options.method, "PUT");
372
+ assert.equal(options.followRedirect, false);
265
373
  assert.equal(options.https.rejectUnauthorized, !skipDtTlsCheck);
266
374
  assert.equal(options.headers["X-Api-Key"], apiKey);
267
375
  assert.match(options.headers["user-agent"], /@CycloneDX\/cdxgen/);
@@ -328,6 +436,41 @@ describe("CLI tests", () => {
328
436
  assert.equal(response, undefined);
329
437
  sinon.assert.notCalled(gotStub);
330
438
  });
439
+
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(
460
+ {
461
+ serverUrl: "https://dtrack.example.com",
462
+ projectName: "cdxgen-test-project",
463
+ apiKey: "TEST_API_KEY\r\n",
464
+ },
465
+ { bom: "test6" },
466
+ );
467
+
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");
473
+ });
331
474
  });
332
475
 
333
476
  describe("createCocoaBom()", () => {
@@ -654,6 +797,103 @@ describe("CLI tests", () => {
654
797
  rmSync(tempDir, { recursive: true, force: true });
655
798
  }
656
799
  });
800
+
801
+ it("treats an existing local directory as a staged rootfs for docker scans", async () => {
802
+ const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-rootfs-"));
803
+ const exportImage = sinon.stub().resolves(undefined);
804
+ const getPkgPathList = sinon.stub().returns([]);
805
+ try {
806
+ const { createBom: createBomMocked } = await esmock("./index.js", {
807
+ "../managers/binary.js": {
808
+ executeOsQuery: sinon.stub(),
809
+ getBinaryBom: sinon.stub(),
810
+ getDotnetSlices: sinon.stub(),
811
+ getOSPackages: sinon.stub().resolves({
812
+ allTypes: [],
813
+ binPaths: [],
814
+ bundledRuntimes: [],
815
+ bundledSdks: [],
816
+ dependenciesList: [],
817
+ executables: [],
818
+ osPackages: [],
819
+ sharedLibs: [],
820
+ }),
821
+ },
822
+ "../managers/docker.js": {
823
+ addSkippedSrcFiles: sinon.stub(),
824
+ exportArchive: sinon.stub(),
825
+ exportImage,
826
+ getPkgPathList,
827
+ parseImageName: sinon.stub(),
828
+ },
829
+ });
830
+ const bomNSData = await createBomMocked(tempDir, {
831
+ failOnError: true,
832
+ installDeps: false,
833
+ multiProject: false,
834
+ projectType: ["docker"],
835
+ specVersion: 1.6,
836
+ });
837
+ sinon.assert.notCalled(exportImage);
838
+ sinon.assert.calledOnce(getPkgPathList);
839
+ assert.ok(bomNSData?.bomJson);
840
+ assert.strictEqual(bomNSData?.parentComponent?.type, "container");
841
+ } finally {
842
+ rmSync(tempDir, { recursive: true, force: true });
843
+ }
844
+ });
845
+
846
+ it("prefers an all-layers subdirectory when scanning staged rootfs inputs", async () => {
847
+ const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-rootfs-"));
848
+ const allLayersDir = join(tempDir, "all-layers");
849
+ const exportImage = sinon.stub().resolves(undefined);
850
+ const getPkgPathList = sinon.stub().returns([]);
851
+ mkdirSync(allLayersDir);
852
+ try {
853
+ const { createBom: createBomMocked } = await esmock("./index.js", {
854
+ "../managers/binary.js": {
855
+ executeOsQuery: sinon.stub(),
856
+ getBinaryBom: sinon.stub(),
857
+ getDotnetSlices: sinon.stub(),
858
+ getOSPackages: sinon.stub().resolves({
859
+ allTypes: [],
860
+ binPaths: [],
861
+ bundledRuntimes: [],
862
+ bundledSdks: [],
863
+ dependenciesList: [],
864
+ executables: [],
865
+ osPackages: [],
866
+ sharedLibs: [],
867
+ }),
868
+ },
869
+ "../managers/docker.js": {
870
+ addSkippedSrcFiles: sinon.stub(),
871
+ exportArchive: sinon.stub(),
872
+ exportImage,
873
+ getPkgPathList,
874
+ parseImageName: sinon.stub(),
875
+ },
876
+ });
877
+ await createBomMocked(tempDir, {
878
+ failOnError: true,
879
+ installDeps: false,
880
+ multiProject: false,
881
+ projectType: ["docker"],
882
+ specVersion: 1.6,
883
+ });
884
+ sinon.assert.calledOnce(getPkgPathList);
885
+ assert.strictEqual(
886
+ getPkgPathList.firstCall.args[0].allLayersDir,
887
+ tempDir,
888
+ );
889
+ assert.strictEqual(
890
+ getPkgPathList.firstCall.args[0].allLayersExplodedDir,
891
+ allLayersDir,
892
+ );
893
+ } finally {
894
+ rmSync(tempDir, { recursive: true, force: true });
895
+ }
896
+ });
657
897
  });
658
898
 
659
899
  describe("createBom() cargo cache support", () => {
@@ -848,6 +1088,102 @@ checksum = "${"a".repeat(64)}"
848
1088
  });
849
1089
  });
850
1090
 
1091
+ describe("createBom() Collider lock support", () => {
1092
+ it("preserves Collider integrity metadata and dependency nodes in the BOM", async () => {
1093
+ const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-collider-"));
1094
+ writeFileSync(
1095
+ join(tmpDir, "collider.lock"),
1096
+ JSON.stringify(
1097
+ {
1098
+ version: 1,
1099
+ dependencies: {
1100
+ fmt: {
1101
+ version: "11.0.2",
1102
+ wrap_hash: `sha256:${"a".repeat(64)}`,
1103
+ origin: "https://packages.example.com/collider/v2/",
1104
+ },
1105
+ },
1106
+ packages: {
1107
+ fast_float: {
1108
+ version: "8.0.2",
1109
+ wrap_hash: `sha256:${"b".repeat(64)}`,
1110
+ origin: "https://wrapdb.mesonbuild.com/v2/",
1111
+ },
1112
+ },
1113
+ },
1114
+ null,
1115
+ 2,
1116
+ ),
1117
+ );
1118
+ try {
1119
+ const bomNSData = await createBom(tmpDir, {
1120
+ failOnError: true,
1121
+ installDeps: false,
1122
+ multiProject: false,
1123
+ projectType: ["collider"],
1124
+ specVersion: 1.7,
1125
+ });
1126
+ const bomJson = bomNSData?.bomJson || {};
1127
+ const fmtComponent = (bomJson.components || []).find(
1128
+ (component) => component.name === "fmt",
1129
+ );
1130
+ const transitiveComponent = (bomJson.components || []).find(
1131
+ (component) => component.name === "fast_float",
1132
+ );
1133
+ assert.ok(fmtComponent);
1134
+ assert.ok(transitiveComponent);
1135
+ assert.deepStrictEqual(
1136
+ getProp(fmtComponent, "cdx:collider:origin"),
1137
+ "https://packages.example.com/collider/v2/",
1138
+ );
1139
+ assert.deepStrictEqual(
1140
+ getProp(fmtComponent, "cdx:collider:hasWrapHash"),
1141
+ "true",
1142
+ );
1143
+ assert.deepStrictEqual(
1144
+ getProp(transitiveComponent, "cdx:collider:dependencyKind"),
1145
+ "transitive",
1146
+ );
1147
+ assert.deepStrictEqual(fmtComponent.hashes, [
1148
+ {
1149
+ alg: "SHA-256",
1150
+ content: "a".repeat(64),
1151
+ },
1152
+ ]);
1153
+ assert.deepStrictEqual(fmtComponent.externalReferences, [
1154
+ {
1155
+ type: "distribution",
1156
+ url: "https://packages.example.com/collider/v2/",
1157
+ },
1158
+ ]);
1159
+ const parentDependency = (bomJson.dependencies || []).find(
1160
+ (dependency) =>
1161
+ dependency.ref === bomJson.metadata.component["bom-ref"],
1162
+ );
1163
+ assert.ok(parentDependency);
1164
+ assert.deepStrictEqual(parentDependency.dependsOn, [
1165
+ "pkg:generic/fmt@11.0.2",
1166
+ ]);
1167
+ assert.ok(
1168
+ (bomJson.dependencies || []).some(
1169
+ (dependency) =>
1170
+ dependency.ref === "pkg:generic/fmt@11.0.2" &&
1171
+ dependency.dependsOn.length === 0,
1172
+ ),
1173
+ );
1174
+ assert.ok(
1175
+ (bomJson.dependencies || []).some(
1176
+ (dependency) =>
1177
+ dependency.ref === "pkg:generic/fast_float@8.0.2" &&
1178
+ dependency.dependsOn.length === 0,
1179
+ ),
1180
+ );
1181
+ } finally {
1182
+ rmSync(tmpDir, { force: true, recursive: true });
1183
+ }
1184
+ });
1185
+ });
1186
+
851
1187
  describe("createBom() MCP inventory support", () => {
852
1188
  it("catalogs MCP services, primitives, and audit findings for JavaScript projects", async () => {
853
1189
  const options = {
@@ -963,6 +1299,128 @@ checksum = "${"a".repeat(64)}"
963
1299
  );
964
1300
  });
965
1301
 
1302
+ it("flags disabled setup caches for npm, Python, and Cargo fixtures", async () => {
1303
+ const options = {
1304
+ bomAudit: true,
1305
+ bomAuditCategories: "ci-permission",
1306
+ bomAuditMinSeverity: "low",
1307
+ failOnError: true,
1308
+ includeFormulation: true,
1309
+ installDeps: false,
1310
+ multiProject: true,
1311
+ projectType: ["js", "python", "cargo", "github"],
1312
+ specVersion: 1.7,
1313
+ };
1314
+ const bomNSData = await createBom(cacheDisableFixtureDir, options);
1315
+ const processedBomNSData = postProcess(
1316
+ bomNSData,
1317
+ options,
1318
+ cacheDisableFixtureDir,
1319
+ );
1320
+ const bomJson = processedBomNSData?.bomJson || {};
1321
+ const setupNodeComponent = (bomJson.components || []).find(
1322
+ (component) =>
1323
+ getProp(component, "cdx:github:action:uses") ===
1324
+ "actions/setup-node@v4",
1325
+ );
1326
+ const setupPythonComponent = (bomJson.components || []).find(
1327
+ (component) =>
1328
+ getProp(component, "cdx:github:action:uses") ===
1329
+ "actions/setup-python@v5",
1330
+ );
1331
+ const setupRustComponent = (bomJson.components || []).find(
1332
+ (component) =>
1333
+ getProp(component, "cdx:github:action:uses") ===
1334
+ "moonrepo/setup-rust@v1",
1335
+ );
1336
+ const npmComponent = (bomJson.components || []).find((component) =>
1337
+ component.purl?.startsWith("pkg:npm/left-pad@1.3.0"),
1338
+ );
1339
+ const pythonComponent = (bomJson.components || []).find((component) =>
1340
+ component.purl?.startsWith("pkg:pypi/anyio@4.6.0"),
1341
+ );
1342
+ const cargoComponent = (bomJson.components || []).find(
1343
+ (component) =>
1344
+ component.name === "git-crate" &&
1345
+ getProp(component, "cdx:cargo:git") ===
1346
+ "https://github.com/acme/git-crate.git",
1347
+ );
1348
+ const cargoRunComponent = (bomJson.components || []).find((component) =>
1349
+ component.properties?.some(
1350
+ (property) =>
1351
+ property.name === "cdx:github:step:cargoSubcommands" &&
1352
+ property.value === "build",
1353
+ ),
1354
+ );
1355
+ assert.ok(setupNodeComponent, "expected setup-node workflow component");
1356
+ assert.ok(
1357
+ setupPythonComponent,
1358
+ "expected setup-python workflow component",
1359
+ );
1360
+ assert.ok(setupRustComponent, "expected setup-rust workflow component");
1361
+ assert.strictEqual(
1362
+ getProp(setupNodeComponent, "cdx:github:action:disablesBuildCache"),
1363
+ "true",
1364
+ );
1365
+ assert.strictEqual(
1366
+ getProp(setupPythonComponent, "cdx:github:action:disablesBuildCache"),
1367
+ "true",
1368
+ );
1369
+ assert.strictEqual(
1370
+ getProp(setupRustComponent, "cdx:github:action:disablesBuildCache"),
1371
+ "true",
1372
+ );
1373
+ assert.strictEqual(
1374
+ getProp(setupRustComponent, "cdx:github:action:buildCacheEcosystem"),
1375
+ "cargo",
1376
+ );
1377
+ assert.strictEqual(
1378
+ getProp(setupRustComponent, "cdx:github:action:buildCacheDisableInput"),
1379
+ "cache",
1380
+ );
1381
+ assert.ok(npmComponent, "expected npm dependency from package-lock");
1382
+ assert.ok(pythonComponent, "expected PyPI dependency from uv.lock");
1383
+ assert.ok(cargoComponent, "expected Cargo dependency from Cargo.toml");
1384
+ assert.ok(cargoRunComponent, "expected Cargo run step component");
1385
+ assert.strictEqual(
1386
+ getProp(npmComponent, "cdx:npm:manifestSourceType"),
1387
+ "url",
1388
+ );
1389
+ assert.strictEqual(
1390
+ getProp(pythonComponent, "cdx:pypi:manifestSourceType"),
1391
+ "url",
1392
+ );
1393
+ assert.strictEqual(
1394
+ getProp(cargoComponent, "cdx:cargo:git"),
1395
+ "https://github.com/acme/git-crate.git",
1396
+ );
1397
+ assert.strictEqual(
1398
+ getProp(cargoComponent, "cdx:cargo:gitBranch"),
1399
+ "main",
1400
+ );
1401
+ assert.strictEqual(
1402
+ getProp(cargoRunComponent, "cdx:github:step:usesCargo"),
1403
+ "true",
1404
+ );
1405
+
1406
+ const findings = await auditBom(bomJson, {
1407
+ bomAuditCategories: "ci-permission",
1408
+ bomAuditMinSeverity: "low",
1409
+ });
1410
+ assert.ok(
1411
+ findings.some((finding) => finding.ruleId === "CI-022"),
1412
+ "expected npm disabled cache finding",
1413
+ );
1414
+ assert.ok(
1415
+ findings.some((finding) => finding.ruleId === "CI-023"),
1416
+ "expected Python disabled cache finding",
1417
+ );
1418
+ assert.ok(
1419
+ findings.some((finding) => finding.ruleId === "CI-024"),
1420
+ "expected Cargo disabled cache finding",
1421
+ );
1422
+ });
1423
+
966
1424
  it("supports exact AI skill scans and js exclude-type filtering for AI inventory", async () => {
967
1425
  const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-ai-inventory-"));
968
1426
  mkdirSync(join(tmpDir, ".claude", "skills", "release"), {
@@ -1223,4 +1681,124 @@ checksum = "${"a".repeat(64)}"
1223
1681
  }
1224
1682
  });
1225
1683
  });
1684
+
1685
+ describe("node_modules multi-ecosystem filtering", () => {
1686
+ it("ignores composer manifests in node_modules during mixed npm/php scans", () => {
1687
+ const tmpDir = createComposerNodeModulesFixture();
1688
+ try {
1689
+ const bomData = createPHPBom(tmpDir, {
1690
+ installDeps: false,
1691
+ multiProject: true,
1692
+ projectType: ["js", "php"],
1693
+ specVersion: 1.7,
1694
+ });
1695
+ assert.deepStrictEqual(bomData, {});
1696
+ } finally {
1697
+ rmSync(tmpDir, { force: true, recursive: true });
1698
+ }
1699
+ });
1700
+
1701
+ it("still allows explicit php scans to inspect composer manifests in node_modules", () => {
1702
+ const tmpDir = createComposerNodeModulesFixture();
1703
+ try {
1704
+ const bomData = createPHPBom(tmpDir, {
1705
+ installDeps: false,
1706
+ multiProject: true,
1707
+ projectType: ["php"],
1708
+ specVersion: 1.7,
1709
+ });
1710
+ assert.ok(bomData?.bomJson?.components?.length);
1711
+ } finally {
1712
+ rmSync(tmpDir, { force: true, recursive: true });
1713
+ }
1714
+ });
1715
+
1716
+ it("still allows direct php scans without projectType to inspect composer manifests in node_modules", () => {
1717
+ const tmpDir = createComposerNodeModulesFixture();
1718
+ try {
1719
+ const bomData = createPHPBom(tmpDir, {
1720
+ installDeps: false,
1721
+ multiProject: true,
1722
+ specVersion: 1.7,
1723
+ });
1724
+ assert.ok(bomData?.bomJson?.components?.length);
1725
+ } finally {
1726
+ rmSync(tmpDir, { force: true, recursive: true });
1727
+ }
1728
+ });
1729
+
1730
+ it("still allows explicit php alias combinations to inspect composer manifests in node_modules", () => {
1731
+ const tmpDir = createComposerNodeModulesFixture();
1732
+ try {
1733
+ const bomData = createPHPBom(tmpDir, {
1734
+ installDeps: false,
1735
+ multiProject: true,
1736
+ projectType: ["php", "composer"],
1737
+ specVersion: 1.7,
1738
+ });
1739
+ assert.ok(bomData?.bomJson?.components?.length);
1740
+ } finally {
1741
+ rmSync(tmpDir, { force: true, recursive: true });
1742
+ }
1743
+ });
1744
+
1745
+ it("ignores jar artifacts in node_modules during mixed npm/jar scans", async () => {
1746
+ const tmpDir = createJarNodeModulesFixture();
1747
+ try {
1748
+ const createJarBom = await loadStubbedCreateJarBom();
1749
+ const bomData = await createJarBom(tmpDir, {
1750
+ multiProject: true,
1751
+ projectType: ["js", "jar"],
1752
+ specVersion: 1.7,
1753
+ });
1754
+ assert.strictEqual(bomData?.bomJson?.components?.length || 0, 0);
1755
+ } finally {
1756
+ rmSync(tmpDir, { force: true, recursive: true });
1757
+ }
1758
+ });
1759
+
1760
+ it("still allows explicit jar scans to inspect node_modules artifacts", async () => {
1761
+ const tmpDir = createJarNodeModulesFixture();
1762
+ try {
1763
+ const createJarBom = await loadStubbedCreateJarBom();
1764
+ const bomData = await createJarBom(tmpDir, {
1765
+ multiProject: true,
1766
+ projectType: ["jar"],
1767
+ specVersion: 1.7,
1768
+ });
1769
+ assert.ok(bomData?.bomJson?.components?.length);
1770
+ } finally {
1771
+ rmSync(tmpDir, { force: true, recursive: true });
1772
+ }
1773
+ });
1774
+
1775
+ it("still allows direct jar scans without projectType to inspect node_modules artifacts", async () => {
1776
+ const tmpDir = createJarNodeModulesFixture();
1777
+ try {
1778
+ const createJarBom = await loadStubbedCreateJarBom();
1779
+ const bomData = await createJarBom(tmpDir, {
1780
+ multiProject: true,
1781
+ specVersion: 1.7,
1782
+ });
1783
+ assert.ok(bomData?.bomJson?.components?.length);
1784
+ } finally {
1785
+ rmSync(tmpDir, { force: true, recursive: true });
1786
+ }
1787
+ });
1788
+
1789
+ it("still allows explicit jar alias combinations to inspect node_modules artifacts", async () => {
1790
+ const tmpDir = createJarNodeModulesFixture();
1791
+ try {
1792
+ const createJarBom = await loadStubbedCreateJarBom();
1793
+ const bomData = await createJarBom(tmpDir, {
1794
+ multiProject: true,
1795
+ projectType: ["jar", "war"],
1796
+ specVersion: 1.7,
1797
+ });
1798
+ assert.ok(bomData?.bomJson?.components?.length);
1799
+ } finally {
1800
+ rmSync(tmpDir, { force: true, recursive: true });
1801
+ }
1802
+ });
1803
+ });
1226
1804
  });
@@ -7,6 +7,7 @@ import {
7
7
  providerNamesForText,
8
8
  sanitizeMcpRefToken,
9
9
  } from "./mcpDiscovery.js";
10
+ import { sanitizeBomUrl } from "./propertySanitizer.js";
10
11
  import { scanTextForHiddenUnicode } from "./unicodeScan.js";
11
12
 
12
13
  const AGENT_FILE_PATTERNS = [
@@ -125,7 +126,7 @@ function buildInferredMcpServices(filePath, mcpUrls, authHints, providerNames) {
125
126
  "bom-ref": `urn:service:agent-mcp:${sanitizeMcpRefToken(hostname || basename(filePath))}:${index + 1}`,
126
127
  group: "mcp",
127
128
  name: hostname || `${basename(filePath)}-mcp-endpoint`,
128
- endpoints: [urlValue],
129
+ endpoints: [sanitizeBomUrl(urlValue)],
129
130
  properties,
130
131
  version: "inferred",
131
132
  };
@@ -233,10 +234,13 @@ export const agentFormulationParser = {
233
234
  }
234
235
  }
235
236
  if (mcpUrls.length) {
237
+ const sanitizedMcpUrls = mcpUrls.map((urlValue) =>
238
+ sanitizeBomUrl(urlValue),
239
+ );
236
240
  properties.push(
237
241
  {
238
242
  name: "cdx:agent:hiddenMcpUrls",
239
- value: mcpUrls.join(","),
243
+ value: sanitizedMcpUrls.join(","),
240
244
  },
241
245
  {
242
246
  name: "cdx:agent:hiddenMcpHosts",