@cyclonedx/cdxgen 12.3.1 → 12.3.2

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 (56) hide show
  1. package/bin/cdxgen.js +1 -2
  2. package/data/rules/ai-agent-governance.yaml +43 -0
  3. package/data/rules/mcp-servers.yaml +36 -2
  4. package/lib/cli/index.js +295 -17
  5. package/lib/cli/index.poku.js +296 -1
  6. package/lib/helpers/agentFormulationParser.js +4 -1
  7. package/lib/helpers/aiInventory.js +262 -0
  8. package/lib/helpers/aiInventory.poku.js +111 -0
  9. package/lib/helpers/analyzer.js +375 -45
  10. package/lib/helpers/analyzer.poku.js +50 -0
  11. package/lib/helpers/auditCategories.js +76 -0
  12. package/lib/helpers/display.js +5 -2
  13. package/lib/helpers/display.poku.js +25 -0
  14. package/lib/helpers/formulationParsers.js +26 -6
  15. package/lib/helpers/jsonLike.js +21 -20
  16. package/lib/helpers/jsonLike.poku.js +34 -0
  17. package/lib/helpers/mcpConfigParser.js +11 -11
  18. package/lib/helpers/mcpConfigParser.poku.js +67 -0
  19. package/lib/helpers/mcpDiscovery.js +13 -23
  20. package/lib/helpers/mcpDiscovery.poku.js +21 -0
  21. package/lib/helpers/utils.js +2 -1
  22. package/lib/helpers/utils.poku.js +19 -1
  23. package/lib/stages/postgen/annotator.js +2 -1
  24. package/lib/stages/postgen/annotator.poku.js +15 -0
  25. package/lib/stages/postgen/auditBom.js +12 -6
  26. package/lib/stages/postgen/auditBom.poku.js +111 -4
  27. package/lib/stages/postgen/postgen.js +229 -6
  28. package/lib/stages/postgen/postgen.poku.js +180 -0
  29. package/package.json +1 -1
  30. package/types/lib/cli/index.d.ts +1 -0
  31. package/types/lib/cli/index.d.ts.map +1 -1
  32. package/types/lib/helpers/agentFormulationParser.d.ts +19 -0
  33. package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -0
  34. package/types/lib/helpers/aiInventory.d.ts +23 -0
  35. package/types/lib/helpers/aiInventory.d.ts.map +1 -0
  36. package/types/lib/helpers/analyzer.d.ts +5 -0
  37. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  38. package/types/lib/helpers/auditCategories.d.ts +12 -0
  39. package/types/lib/helpers/auditCategories.d.ts.map +1 -0
  40. package/types/lib/helpers/communityAiConfigParser.d.ts +29 -0
  41. package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -0
  42. package/types/lib/helpers/display.d.ts.map +1 -1
  43. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  44. package/types/lib/helpers/jsonLike.d.ts +4 -0
  45. package/types/lib/helpers/jsonLike.d.ts.map +1 -0
  46. package/types/lib/helpers/mcp.d.ts +29 -0
  47. package/types/lib/helpers/mcp.d.ts.map +1 -0
  48. package/types/lib/helpers/mcpConfigParser.d.ts +30 -0
  49. package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -0
  50. package/types/lib/helpers/mcpDiscovery.d.ts +5 -0
  51. package/types/lib/helpers/mcpDiscovery.d.ts.map +1 -0
  52. package/types/lib/helpers/utils.d.ts +2 -0
  53. package/types/lib/helpers/utils.d.ts.map +1 -1
  54. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  55. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  56. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
@@ -1,22 +1,32 @@
1
+ /**
2
+ * Returns whether the quote at `index` is escaped by an odd-length run of
3
+ * backslashes immediately preceding it.
4
+ *
5
+ * @param {string} raw Raw JSON-like text being scanned
6
+ * @param {number} index Index of the quote character to evaluate
7
+ * @returns {boolean} `true` when the quote is escaped and should not terminate
8
+ * the current string literal
9
+ */
10
+ function isEscapedQuote(raw, index) {
11
+ let backslashCount = 0;
12
+ let lookBehind = index - 1;
13
+ while (lookBehind >= 0 && raw[lookBehind] === "\\") {
14
+ backslashCount += 1;
15
+ lookBehind -= 1;
16
+ }
17
+ return backslashCount % 2 === 1;
18
+ }
19
+
1
20
  export function stripJsonComments(raw) {
2
21
  let output = "";
3
22
  let inString = false;
4
23
  let stringQuote = "";
5
- let escaped = false;
6
24
  for (let index = 0; index < raw.length; index++) {
7
25
  const char = raw[index];
8
26
  const nextChar = raw[index + 1];
9
27
  if (inString) {
10
28
  output += char;
11
- if (escaped) {
12
- escaped = false;
13
- continue;
14
- }
15
- if (char === "\\") {
16
- escaped = true;
17
- continue;
18
- }
19
- if (char === stringQuote) {
29
+ if (char === stringQuote && !isEscapedQuote(raw, index)) {
20
30
  inString = false;
21
31
  stringQuote = "";
22
32
  }
@@ -57,20 +67,11 @@ export function stripJsonTrailingCommas(raw) {
57
67
  let output = "";
58
68
  let inString = false;
59
69
  let stringQuote = "";
60
- let escaped = false;
61
70
  for (let index = 0; index < raw.length; index++) {
62
71
  const char = raw[index];
63
72
  if (inString) {
64
73
  output += char;
65
- if (escaped) {
66
- escaped = false;
67
- continue;
68
- }
69
- if (char === "\\") {
70
- escaped = true;
71
- continue;
72
- }
73
- if (char === stringQuote) {
74
+ if (char === stringQuote && !isEscapedQuote(raw, index)) {
74
75
  inString = false;
75
76
  stringQuote = "";
76
77
  }
@@ -0,0 +1,34 @@
1
+ import { assert, describe, it } from "poku";
2
+
3
+ import { parseJsonLike, stripJsonComments } from "./jsonLike.js";
4
+
5
+ describe("jsonLike", () => {
6
+ it("preserves escaped quotes while stripping comments", () => {
7
+ const parsedMessage = 'escaped quote: " // not a comment';
8
+ const rawMessage = String.raw`escaped quote: \" // not a comment`;
9
+ const raw = String.raw`{
10
+ "message": "escaped quote: \" // not a comment",
11
+ // trailing comment
12
+ "enabled": true
13
+ }`;
14
+ const stripped = stripJsonComments(raw);
15
+ assert.ok(stripped.includes(rawMessage));
16
+ assert.ok(!stripped.includes("trailing comment"));
17
+ assert.deepStrictEqual(parseJsonLike(raw), {
18
+ enabled: true,
19
+ message: parsedMessage,
20
+ });
21
+ });
22
+
23
+ it("preserves comment markers after escaped backslashes inside strings", () => {
24
+ const raw = `{
25
+ "path": "C:\\\\\\\\temp\\\\\\\\file // keep",
26
+ /* block comment */
27
+ "count": 1
28
+ }`;
29
+ assert.deepStrictEqual(parseJsonLike(raw), {
30
+ count: 1,
31
+ path: "C:\\\\temp\\\\file // keep",
32
+ });
33
+ });
34
+ });
@@ -214,6 +214,9 @@ function detectConfigCredentialSignals(serverConfig) {
214
214
  }
215
215
  }
216
216
  return {
217
+ credentialIndicatorCount: inlineIndicators.size,
218
+ credentialReferenceCount: credentialRefs.size,
219
+ exposureFieldCount: exposureFields.size,
217
220
  credentialRefs: Array.from(credentialRefs).sort(),
218
221
  exposureFields: Array.from(exposureFields).sort(),
219
222
  inlineIndicators: Array.from(inlineIndicators).sort(),
@@ -468,22 +471,22 @@ function createServiceFromConfig(
468
471
  addUniqueProperty(properties, "cdx:mcp:credentialExposure", "true");
469
472
  addUniqueProperty(
470
473
  properties,
471
- "cdx:mcp:credentialRiskIndicators",
472
- credentialSignals.inlineIndicators.join(","),
474
+ "cdx:mcp:credentialIndicatorCount",
475
+ String(credentialSignals.credentialIndicatorCount),
473
476
  );
474
477
  }
475
478
  if (credentialSignals.exposureFields.length) {
476
479
  addUniqueProperty(
477
480
  properties,
478
- "cdx:mcp:credentialExposureFields",
479
- credentialSignals.exposureFields.join(","),
481
+ "cdx:mcp:credentialExposureFieldCount",
482
+ String(credentialSignals.exposureFieldCount),
480
483
  );
481
484
  }
482
485
  if (credentialSignals.credentialRefs.length) {
483
486
  addUniqueProperty(
484
487
  properties,
485
- "cdx:mcp:credentialRefs",
486
- credentialSignals.credentialRefs.join(","),
488
+ "cdx:mcp:credentialReferenceCount",
489
+ String(credentialSignals.credentialReferenceCount),
487
490
  );
488
491
  }
489
492
  if (supportsDcr) {
@@ -593,11 +596,8 @@ function createConfigComponent(filePath, format, raw, services) {
593
596
  addUniqueProperty(properties, "cdx:mcp:credentialExposure", "true");
594
597
  addUniqueProperty(
595
598
  properties,
596
- "cdx:mcp:credentialExposedServices",
597
- credentialServices
598
- .map((service) => service.name)
599
- .sort()
600
- .join(","),
599
+ "cdx:mcp:credentialExposedServiceCount",
600
+ String(credentialServices.length),
601
601
  );
602
602
  }
603
603
  return {
@@ -56,4 +56,71 @@ describe("mcpConfigParser", () => {
56
56
  syntax: "json",
57
57
  });
58
58
  });
59
+
60
+ it("records credential exposure without embedding raw secret metadata", async () => {
61
+ const readFileSync = sinon.stub();
62
+ const scanTextForHiddenUnicode = sinon.stub().returns({
63
+ hasHiddenUnicode: false,
64
+ });
65
+ readFileSync.withArgs("/repo/.vscode/mcp.json", "utf-8").returns(
66
+ JSON.stringify({
67
+ mcpServers: {
68
+ releaseDocs: {
69
+ args: [
70
+ "--token",
71
+ "sk_test_super_secret_value",
72
+ "https://docs.example.com/mcp",
73
+ ],
74
+ command: "npx",
75
+ env: {
76
+ API_KEY: "$" + "{API_KEY}",
77
+ },
78
+ headers: {
79
+ Authorization: "Bearer sk_test_another_secret_value",
80
+ },
81
+ transport: "http",
82
+ },
83
+ },
84
+ }),
85
+ );
86
+ const { mcpConfigParser } = await esmock("./mcpConfigParser.js", {
87
+ "node:fs": { readFileSync },
88
+ "./unicodeScan.js": { scanTextForHiddenUnicode },
89
+ });
90
+
91
+ const result = mcpConfigParser.parse(["/repo/.vscode/mcp.json"]);
92
+ const service = result.services[0];
93
+ const component = result.components[0];
94
+
95
+ assert.strictEqual(getProp(service, "cdx:mcp:credentialExposure"), "true");
96
+ assert.strictEqual(
97
+ getProp(service, "cdx:mcp:credentialIndicatorCount"),
98
+ "3",
99
+ );
100
+ assert.strictEqual(
101
+ getProp(service, "cdx:mcp:credentialExposureFieldCount"),
102
+ "3",
103
+ );
104
+ assert.strictEqual(
105
+ getProp(service, "cdx:mcp:credentialReferenceCount"),
106
+ "1",
107
+ );
108
+ assert.strictEqual(
109
+ getProp(component, "cdx:mcp:credentialExposedServiceCount"),
110
+ "1",
111
+ );
112
+ assert.strictEqual(
113
+ getProp(service, "cdx:mcp:credentialRiskIndicators"),
114
+ undefined,
115
+ );
116
+ assert.strictEqual(
117
+ getProp(service, "cdx:mcp:credentialExposureFields"),
118
+ undefined,
119
+ );
120
+ assert.strictEqual(getProp(service, "cdx:mcp:credentialRefs"), undefined);
121
+ assert.strictEqual(
122
+ getProp(component, "cdx:mcp:credentialExposedServices"),
123
+ undefined,
124
+ );
125
+ });
59
126
  });
@@ -17,30 +17,20 @@ const INLINE_CREDENTIAL_PATTERNS = [
17
17
  ];
18
18
 
19
19
  export function sanitizeMcpRefToken(value) {
20
- const input = String(value || "").toLowerCase();
21
- const tokens = [];
22
- let previousWasSeparator = false;
23
- for (const char of input) {
24
- const isAlphaNumeric =
25
- (char >= "a" && char <= "z") || (char >= "0" && char <= "9");
26
- const isAllowedPunctuation = [".", "_", "-"].includes(char);
27
- if (isAlphaNumeric || isAllowedPunctuation) {
28
- tokens.push(char);
29
- previousWasSeparator = false;
30
- continue;
31
- }
32
- if (!previousWasSeparator && tokens.length) {
33
- tokens.push("-");
34
- previousWasSeparator = true;
35
- }
20
+ const input = String(value || "")
21
+ .normalize("NFKC")
22
+ .trim()
23
+ .toLowerCase();
24
+ const normalized = input
25
+ .replaceAll(/[/\\:]/gu, "-")
26
+ .replaceAll(/[^a-z0-9._-]+/gu, "-")
27
+ .replaceAll(/[._-]{2,}/gu, "-")
28
+ .replaceAll(/^\.+|\.+$/gu, "")
29
+ .replaceAll(/^[._-]+|[._-]+$/gu, "");
30
+ if (!normalized || normalized === "." || normalized === "..") {
31
+ return "unknown";
36
32
  }
37
- while (tokens[0] === "-") {
38
- tokens.shift();
39
- }
40
- while (tokens[tokens.length - 1] === "-") {
41
- tokens.pop();
42
- }
43
- return tokens.join("") || "unknown";
33
+ return normalized.slice(0, 128);
44
34
  }
45
35
 
46
36
  export function isLocalHost(hostname) {
@@ -0,0 +1,21 @@
1
+ import { assert, describe, it } from "poku";
2
+
3
+ import { sanitizeMcpRefToken } from "./mcpDiscovery.js";
4
+
5
+ describe("sanitizeMcpRefToken()", () => {
6
+ it("normalizes path traversal and punctuation-heavy input into safe tokens", () => {
7
+ assert.strictEqual(
8
+ sanitizeMcpRefToken("../Secrets/Prod Token"),
9
+ "secrets-prod-token",
10
+ );
11
+ assert.strictEqual(
12
+ sanitizeMcpRefToken("..\\..\\etc\\passwd"),
13
+ "etc-passwd",
14
+ );
15
+ });
16
+
17
+ it("returns unknown for empty or separator-only input", () => {
18
+ assert.strictEqual(sanitizeMcpRefToken("..."), "unknown");
19
+ assert.strictEqual(sanitizeMcpRefToken("///"), "unknown");
20
+ });
21
+ });
@@ -918,7 +918,6 @@ export const PROJECT_TYPE_ALIASES = {
918
918
  "node20",
919
919
  "node22",
920
920
  "node23",
921
- "mcp",
922
921
  "js",
923
922
  "javascript",
924
923
  "typescript",
@@ -927,6 +926,8 @@ export const PROJECT_TYPE_ALIASES = {
927
926
  "yarn",
928
927
  "rush",
929
928
  ],
929
+ mcp: ["mcp"],
930
+ "ai-skill": ["ai-skill", "skill", "skills"],
930
931
  py: [
931
932
  "py",
932
933
  "python",
@@ -3133,7 +3133,25 @@ it("parse github actions workflow data", () => {
3133
3133
  dep_list = parseGitHubWorkflowData("./test/data/github-actions-tj.yaml");
3134
3134
  assert.deepStrictEqual(dep_list.length, 4);
3135
3135
  dep_list = parseGitHubWorkflowData("./.github/workflows/repotests.yml");
3136
- assert.deepStrictEqual(dep_list.length, 92);
3136
+ assert.ok(dep_list.length > 0);
3137
+ assert.ok(
3138
+ dep_list.every((component) =>
3139
+ component.properties?.some(
3140
+ (property) =>
3141
+ property.name === "cdx:github:workflow:file" &&
3142
+ property.value === "./.github/workflows/repotests.yml",
3143
+ ),
3144
+ ),
3145
+ );
3146
+ assert.ok(
3147
+ dep_list.some((component) =>
3148
+ component.properties?.some(
3149
+ (property) =>
3150
+ property.name === "cdx:github:checkout:repository" &&
3151
+ property.value === "AppThreat/vulnerability-db",
3152
+ ),
3153
+ ),
3154
+ );
3137
3155
  });
3138
3156
  // biome-ignore-end lint/suspicious/noTemplateCurlyInString: fp
3139
3157
 
@@ -285,7 +285,8 @@ export function textualMetadata(bomJson) {
285
285
  const { bomType, bomTypeDescription } = findBomType(bomJson);
286
286
  const metadata = bomJson.metadata;
287
287
  const lifecycles = metadata?.lifecycles || [];
288
- const tlpClassification = metadata.distribution;
288
+ const tlpClassification =
289
+ metadata.distributionConstraints?.tlp || metadata.distribution;
289
290
  const cryptoAssetsCount = bomJson?.components?.filter(
290
291
  (c) => c.type === "cryptographic-asset",
291
292
  ).length;
@@ -311,3 +311,18 @@ it("extractTags tests", () => {
311
311
  "security",
312
312
  ]);
313
313
  });
314
+
315
+ it("textualMetadata includes the CycloneDX 1.7 TLP classification from distributionConstraints", () => {
316
+ assert.match(
317
+ textualMetadata({
318
+ bomFormat: "CycloneDX",
319
+ specVersion: "1.7",
320
+ metadata: {
321
+ distributionConstraints: {
322
+ tlp: "AMBER_AND_STRICT",
323
+ },
324
+ },
325
+ }),
326
+ /TLP\) classification for this document is 'AMBER_AND_STRICT'/,
327
+ );
328
+ });
@@ -6,6 +6,10 @@ import { join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  import { buildAnnotationText } from "../../helpers/annotationFormatter.js";
9
+ import {
10
+ expandBomAuditCategories,
11
+ validateBomAuditCategories,
12
+ } from "../../helpers/auditCategories.js";
9
13
  import { table } from "../../helpers/table.js";
10
14
  import {
11
15
  DEBUG_MODE,
@@ -45,15 +49,17 @@ export async function auditBom(bomJson, options) {
45
49
  }
46
50
  let activeRules = rules;
47
51
  if (options.bomAuditCategories) {
48
- const categories = options.bomAuditCategories
49
- .split(",")
50
- .map((c) => c.trim())
51
- .filter(Boolean);
52
+ const { categories, expandedCategories } = validateBomAuditCategories(
53
+ options.bomAuditCategories,
54
+ rules,
55
+ );
52
56
  if (categories.length > 0) {
53
- activeRules = rules.filter((r) => categories.includes(r.category));
57
+ activeRules = rules.filter((r) =>
58
+ expandedCategories.includes(r.category),
59
+ );
54
60
  if (DEBUG_MODE) {
55
61
  console.log(
56
- `Filtering rules by categories: ${categories.join(", ")} (${activeRules.length} active)`,
62
+ `Filtering rules by categories: ${categories.join(", ")} -> ${expandBomAuditCategories(categories).join(", ")} (${activeRules.length} active)`,
57
63
  );
58
64
  }
59
65
  }
@@ -524,13 +524,14 @@ describe("evaluateRule", () => {
524
524
  { name: "cdx:mcp:inventorySource", value: "config-file" },
525
525
  { name: "cdx:mcp:credentialExposure", value: "true" },
526
526
  {
527
- name: "cdx:mcp:credentialExposureFields",
528
- value: "header:Authorization,env:OPENAI_API_KEY",
527
+ name: "cdx:mcp:credentialExposureFieldCount",
528
+ value: "2",
529
529
  },
530
530
  {
531
- name: "cdx:mcp:credentialRiskIndicators",
532
- value: "bearer-token,generic-secret",
531
+ name: "cdx:mcp:credentialIndicatorCount",
532
+ value: "2",
533
533
  },
534
+ { name: "cdx:mcp:credentialReferenceCount", value: "1" },
534
535
  ],
535
536
  },
536
537
  ],
@@ -602,6 +603,56 @@ describe("evaluateRule", () => {
602
603
  assert.ok(findings.length > 0, "Should detect token passthrough risk");
603
604
  });
604
605
 
606
+ it("should flag shipped AI instruction files in build/post-build BOMs (AGT-007)", async () => {
607
+ const rules = await loadRules(RULES_DIR);
608
+ const rule = rules.find((r) => r.id === "AGT-007");
609
+ assert.ok(rule, "AGT-007 rule should exist");
610
+
611
+ const bom = makeBom([
612
+ {
613
+ "bom-ref": "file:/repo/CLAUDE.md",
614
+ name: "CLAUDE.md",
615
+ type: "file",
616
+ properties: [
617
+ { name: "SrcFile", value: "/repo/CLAUDE.md" },
618
+ { name: "cdx:file:kind", value: "agent-instructions" },
619
+ { name: "cdx:agent:inventorySource", value: "agent-file" },
620
+ ],
621
+ },
622
+ ]);
623
+ bom.metadata.lifecycles = [{ phase: "build" }, { phase: "post-build" }];
624
+
625
+ const findings = await evaluateRule(rule, bom);
626
+ assert.ok(findings.length > 0, "Should detect shipped AI instructions");
627
+ assert.strictEqual(findings[0].severity, "medium");
628
+ });
629
+
630
+ it("should flag shipped MCP config files in build/post-build BOMs (MCP-008)", async () => {
631
+ const rules = await loadRules(RULES_DIR);
632
+ const rule = rules.find((r) => r.id === "MCP-008");
633
+ assert.ok(rule, "MCP-008 rule should exist");
634
+
635
+ const bom = makeBom([
636
+ {
637
+ "bom-ref": "file:/repo/.vscode/mcp.json",
638
+ name: "mcp.json",
639
+ type: "file",
640
+ properties: [
641
+ { name: "SrcFile", value: "/repo/.vscode/mcp.json" },
642
+ { name: "cdx:file:kind", value: "mcp-config" },
643
+ { name: "cdx:mcp:configFormat", value: "vscode" },
644
+ { name: "cdx:mcp:configuredServiceCount", value: "1" },
645
+ { name: "cdx:mcp:configuredServiceNames", value: "releaseDocs" },
646
+ ],
647
+ },
648
+ ]);
649
+ bom.metadata.lifecycles = [{ phase: "build" }];
650
+
651
+ const findings = await evaluateRule(rule, bom);
652
+ assert.ok(findings.length > 0, "Should detect shipped MCP config");
653
+ assert.strictEqual(findings[0].severity, "medium");
654
+ });
655
+
605
656
  it("should detect npm name mismatch (INT-002)", async () => {
606
657
  const rules = await loadRules(RULES_DIR);
607
658
  const rule = rules.find((r) => r.id === "INT-002");
@@ -2301,6 +2352,62 @@ describe("auditBom", () => {
2301
2352
  }
2302
2353
  });
2303
2354
 
2355
+ it("expands the ai-inventory category alias", async () => {
2356
+ const bom = makeBom(
2357
+ [],
2358
+ [],
2359
+ [
2360
+ {
2361
+ type: "application",
2362
+ name: "agent-guide",
2363
+ version: "latest",
2364
+ "bom-ref": "file:/repo/AGENTS.md",
2365
+ properties: [
2366
+ { name: "SrcFile", value: "/repo/AGENTS.md" },
2367
+ { name: "cdx:agent:inventorySource", value: "agent-file" },
2368
+ { name: "cdx:file:kind", value: "agent-instructions" },
2369
+ {
2370
+ name: "cdx:agent:hasNonOfficialMcpReference",
2371
+ value: "true",
2372
+ },
2373
+ { name: "cdx:agent:mcpPackageRefs", value: "@acme/mcp-server" },
2374
+ ],
2375
+ },
2376
+ ],
2377
+ [
2378
+ {
2379
+ "bom-ref": "urn:service:mcp:demo:1",
2380
+ group: "mcp",
2381
+ name: "demo-server",
2382
+ authenticated: false,
2383
+ endpoints: ["https://mcp.example.com/mcp"],
2384
+ properties: [
2385
+ { name: "cdx:mcp:transport", value: "streamable-http" },
2386
+ { name: "cdx:mcp:serviceType", value: "inferred-endpoint" },
2387
+ { name: "cdx:mcp:inventorySource", value: "agent-file" },
2388
+ { name: "cdx:mcp:toolCount", value: "1" },
2389
+ { name: "cdx:mcp:officialSdk", value: "false" },
2390
+ ],
2391
+ },
2392
+ ],
2393
+ );
2394
+
2395
+ const findings = await auditBom(bom, {
2396
+ bomAuditCategories: "ai-inventory",
2397
+ });
2398
+ assert.ok(findings.some((finding) => finding.category === "ai-agent"));
2399
+ assert.ok(findings.some((finding) => finding.category === "mcp-server"));
2400
+ });
2401
+
2402
+ it("rejects unknown audit categories with valid choices", async () => {
2403
+ await assert.rejects(
2404
+ auditBom(makeBom([]), {
2405
+ bomAuditCategories: "unknown-category",
2406
+ }),
2407
+ /Unknown BOM audit category: unknown-category\. Valid categories: .*ai-inventory \(alias for ai-agent,mcp-server\).*ci-permission.*mcp-server/,
2408
+ );
2409
+ });
2410
+
2304
2411
  it("should filter by minimum severity", async () => {
2305
2412
  const bom = makeBom([
2306
2413
  makeComponent("actions/setup-node", "v3", [