@cyclonedx/cdxgen 12.3.1 → 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 (87) hide show
  1. package/README.md +6 -0
  2. package/bin/cdxgen.js +1 -2
  3. package/data/rules/ai-agent-governance.yaml +43 -0
  4. package/data/rules/ci-permissions.yaml +132 -0
  5. package/data/rules/dependency-sources.yaml +65 -5
  6. package/data/rules/mcp-servers.yaml +36 -2
  7. package/data/rules/package-integrity.yaml +22 -0
  8. package/lib/cli/index.js +436 -56
  9. package/lib/cli/index.poku.js +875 -2
  10. package/lib/helpers/agentFormulationParser.js +10 -3
  11. package/lib/helpers/agentFormulationParser.poku.js +42 -0
  12. package/lib/helpers/aiInventory.js +262 -0
  13. package/lib/helpers/aiInventory.poku.js +111 -0
  14. package/lib/helpers/analyzer.js +413 -54
  15. package/lib/helpers/analyzer.poku.js +117 -0
  16. package/lib/helpers/auditCategories.js +76 -0
  17. package/lib/helpers/chromextutils.js +25 -3
  18. package/lib/helpers/chromextutils.poku.js +68 -0
  19. package/lib/helpers/ciParsers/githubActions.js +79 -0
  20. package/lib/helpers/ciParsers/githubActions.poku.js +103 -0
  21. package/lib/helpers/communityAiConfigParser.js +15 -5
  22. package/lib/helpers/communityAiConfigParser.poku.js +71 -0
  23. package/lib/helpers/depsUtils.js +5 -0
  24. package/lib/helpers/depsUtils.poku.js +55 -0
  25. package/lib/helpers/display.js +50 -24
  26. package/lib/helpers/display.poku.js +70 -58
  27. package/lib/helpers/formulationParsers.js +26 -6
  28. package/lib/helpers/jsonLike.js +21 -20
  29. package/lib/helpers/jsonLike.poku.js +34 -0
  30. package/lib/helpers/mcpConfigParser.js +32 -16
  31. package/lib/helpers/mcpConfigParser.poku.js +104 -0
  32. package/lib/helpers/mcpDiscovery.js +13 -23
  33. package/lib/helpers/mcpDiscovery.poku.js +21 -0
  34. package/lib/helpers/propertySanitizer.js +121 -0
  35. package/lib/helpers/utils.js +953 -41
  36. package/lib/helpers/utils.poku.js +901 -1
  37. package/lib/managers/binary.js +16 -0
  38. package/lib/managers/binary.poku.js +1 -0
  39. package/lib/managers/docker.js +240 -16
  40. package/lib/managers/docker.poku.js +1142 -2
  41. package/lib/server/server.js +7 -4
  42. package/lib/server/server.poku.js +36 -1
  43. package/lib/stages/postgen/annotator.js +2 -1
  44. package/lib/stages/postgen/annotator.poku.js +15 -0
  45. package/lib/stages/postgen/auditBom.js +12 -6
  46. package/lib/stages/postgen/auditBom.poku.js +755 -6
  47. package/lib/stages/postgen/postgen.js +229 -6
  48. package/lib/stages/postgen/postgen.poku.js +180 -0
  49. package/package.json +2 -1
  50. package/types/lib/cli/index.d.ts +1 -0
  51. package/types/lib/cli/index.d.ts.map +1 -1
  52. package/types/lib/helpers/agentFormulationParser.d.ts +19 -0
  53. package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -0
  54. package/types/lib/helpers/aiInventory.d.ts +23 -0
  55. package/types/lib/helpers/aiInventory.d.ts.map +1 -0
  56. package/types/lib/helpers/analyzer.d.ts +5 -0
  57. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  58. package/types/lib/helpers/auditCategories.d.ts +12 -0
  59. package/types/lib/helpers/auditCategories.d.ts.map +1 -0
  60. package/types/lib/helpers/chromextutils.d.ts.map +1 -1
  61. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  62. package/types/lib/helpers/communityAiConfigParser.d.ts +29 -0
  63. package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -0
  64. package/types/lib/helpers/depsUtils.d.ts.map +1 -1
  65. package/types/lib/helpers/display.d.ts +1 -0
  66. package/types/lib/helpers/display.d.ts.map +1 -1
  67. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  68. package/types/lib/helpers/jsonLike.d.ts +4 -0
  69. package/types/lib/helpers/jsonLike.d.ts.map +1 -0
  70. package/types/lib/helpers/mcp.d.ts +29 -0
  71. package/types/lib/helpers/mcp.d.ts.map +1 -0
  72. package/types/lib/helpers/mcpConfigParser.d.ts +30 -0
  73. package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -0
  74. package/types/lib/helpers/mcpDiscovery.d.ts +5 -0
  75. package/types/lib/helpers/mcpDiscovery.d.ts.map +1 -0
  76. package/types/lib/helpers/propertySanitizer.d.ts +3 -0
  77. package/types/lib/helpers/propertySanitizer.d.ts.map +1 -0
  78. package/types/lib/helpers/utils.d.ts +31 -0
  79. package/types/lib/helpers/utils.d.ts.map +1 -1
  80. package/types/lib/managers/binary.d.ts.map +1 -1
  81. package/types/lib/managers/docker.d.ts +3 -0
  82. package/types/lib/managers/docker.d.ts.map +1 -1
  83. package/types/lib/server/server.d.ts +1 -0
  84. package/types/lib/server/server.d.ts.map +1 -1
  85. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  86. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  87. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
@@ -0,0 +1,76 @@
1
+ export const BOM_AUDIT_CATEGORY_ALIASES = Object.freeze({
2
+ "ai-inventory": ["ai-agent", "mcp-server"],
3
+ });
4
+
5
+ function uniqueNonEmptyCategories(categories) {
6
+ return [...new Set((categories || []).filter(Boolean))];
7
+ }
8
+
9
+ export function normalizeBomAuditCategories(categories) {
10
+ if (Array.isArray(categories)) {
11
+ return uniqueNonEmptyCategories(
12
+ categories.map((category) => String(category).trim()).filter(Boolean),
13
+ );
14
+ }
15
+ if (typeof categories !== "string") {
16
+ return [];
17
+ }
18
+ return uniqueNonEmptyCategories(
19
+ categories
20
+ .split(",")
21
+ .map((category) => category.trim())
22
+ .filter(Boolean),
23
+ );
24
+ }
25
+
26
+ export function expandBomAuditCategories(categories) {
27
+ const normalizedCategories = normalizeBomAuditCategories(categories);
28
+ const expandedCategories = [];
29
+ for (const category of normalizedCategories) {
30
+ if (BOM_AUDIT_CATEGORY_ALIASES[category]?.length) {
31
+ expandedCategories.push(...BOM_AUDIT_CATEGORY_ALIASES[category]);
32
+ continue;
33
+ }
34
+ expandedCategories.push(category);
35
+ }
36
+ return uniqueNonEmptyCategories(expandedCategories);
37
+ }
38
+
39
+ export function availableBomAuditCategories(rules) {
40
+ return uniqueNonEmptyCategories(
41
+ (rules || []).map((rule) => rule?.category).filter(Boolean),
42
+ ).sort();
43
+ }
44
+
45
+ function formatBomAuditCategoryOption(category) {
46
+ const aliasedCategories = BOM_AUDIT_CATEGORY_ALIASES[category];
47
+ if (!aliasedCategories?.length) {
48
+ return category;
49
+ }
50
+ return `${category} (alias for ${aliasedCategories.join(",")})`;
51
+ }
52
+
53
+ export function validateBomAuditCategories(categories, rules) {
54
+ const normalizedCategories = normalizeBomAuditCategories(categories);
55
+ const validCategories = availableBomAuditCategories(rules);
56
+ const allowedCategories = new Set([
57
+ ...validCategories,
58
+ ...Object.keys(BOM_AUDIT_CATEGORY_ALIASES),
59
+ ]);
60
+ const invalidCategories = normalizedCategories.filter(
61
+ (category) => !allowedCategories.has(category),
62
+ );
63
+ if (invalidCategories.length) {
64
+ const validCategoryOptions = [...allowedCategories]
65
+ .sort()
66
+ .map((category) => formatBomAuditCategoryOption(category));
67
+ throw new Error(
68
+ `Unknown BOM audit categor${invalidCategories.length === 1 ? "y" : "ies"}: ${invalidCategories.join(", ")}. Valid categories: ${validCategoryOptions.join(", ")}.`,
69
+ );
70
+ }
71
+ return {
72
+ categories: normalizedCategories,
73
+ expandedCategories: expandBomAuditCategories(normalizedCategories),
74
+ validCategories,
75
+ };
76
+ }
@@ -9,6 +9,7 @@ import {
9
9
  CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES,
10
10
  detectExtensionCapabilities,
11
11
  } from "./analyzer.js";
12
+ import { sanitizeBomPropertyValue } from "./propertySanitizer.js";
12
13
  import { isMac, isWin, safeExistsSync } from "./utils.js";
13
14
 
14
15
  /**
@@ -850,7 +851,12 @@ export function toComponent(extInfo) {
850
851
  const component = {
851
852
  name: extensionId,
852
853
  version: extInfo.version || "",
853
- description: extInfo.displayName || extInfo.description || "",
854
+ description: String(
855
+ sanitizeBomPropertyValue(
856
+ "cdx:chrome-extension:description",
857
+ extInfo.displayName || extInfo.description || "",
858
+ ) || "",
859
+ ),
854
860
  purl,
855
861
  "bom-ref": decodeURIComponent(purl),
856
862
  type: "application",
@@ -1035,8 +1041,24 @@ export function toComponent(extInfo) {
1035
1041
  if (extInfo.srcPath) {
1036
1042
  properties.push({ name: "SrcFile", value: extInfo.srcPath });
1037
1043
  }
1038
- if (properties.length) {
1039
- component.properties = properties;
1044
+ const sanitizedProperties = properties
1045
+ .map((property) => {
1046
+ const sanitizedValue = sanitizeBomPropertyValue(
1047
+ property.name,
1048
+ property.value,
1049
+ );
1050
+ if (
1051
+ sanitizedValue === undefined ||
1052
+ sanitizedValue === null ||
1053
+ sanitizedValue === ""
1054
+ ) {
1055
+ return undefined;
1056
+ }
1057
+ return { name: property.name, value: String(sanitizedValue) };
1058
+ })
1059
+ .filter(Boolean);
1060
+ if (sanitizedProperties.length) {
1061
+ component.properties = sanitizedProperties;
1040
1062
  }
1041
1063
  return component;
1042
1064
  }
@@ -142,6 +142,74 @@ describe("parseChromiumExtensionManifest", () => {
142
142
  assert.strictEqual(parsed.hasAutofill, false);
143
143
  });
144
144
 
145
+ it("sanitizes emitted URL properties before they enter the BOM", () => {
146
+ const extensionRoot = join(baseTempDir, "sanitized-extension");
147
+ const extensionId = "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii";
148
+ const extensionVersion = "1.0.0";
149
+ const versionDir = join(extensionRoot, extensionId, extensionVersion);
150
+ mkdirSync(versionDir, { recursive: true });
151
+ writeFileSync(
152
+ join(versionDir, "manifest.json"),
153
+ JSON.stringify({
154
+ manifest_version: 3,
155
+ name: "Sanitized URLs",
156
+ version: extensionVersion,
157
+ update_url: "https://user:pass@example.com/update.xml?token=abc#frag",
158
+ host_permissions: [
159
+ "https://user:pass@example.com/*?token=abc#frag",
160
+ "<all_urls>",
161
+ ],
162
+ externally_connectable: {
163
+ matches: ["https://user:pass@example.com/*?token=abc#frag"],
164
+ },
165
+ }),
166
+ "utf-8",
167
+ );
168
+
169
+ const result = collectChromeExtensionsFromPath(versionDir);
170
+
171
+ assert.strictEqual(
172
+ getProp(result.components[0], "cdx:chrome-extension:updateUrl"),
173
+ "https://example.com/update.xml",
174
+ );
175
+ assert.strictEqual(
176
+ getProp(result.components[0], "cdx:chrome-extension:hostPermissions"),
177
+ "https://example.com/*, <all_urls>",
178
+ );
179
+ assert.strictEqual(
180
+ getProp(
181
+ result.components[0],
182
+ "cdx:chrome-extension:externallyConnectableMatches",
183
+ ),
184
+ "https://example.com/*",
185
+ );
186
+ });
187
+
188
+ it("sanitizes emitted extension descriptions before they enter the BOM", () => {
189
+ const extensionRoot = join(baseTempDir, "sanitized-description-extension");
190
+ const extensionId = "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj";
191
+ const extensionVersion = "1.0.0";
192
+ const versionDir = join(extensionRoot, extensionId, extensionVersion);
193
+ mkdirSync(versionDir, { recursive: true });
194
+ writeFileSync(
195
+ join(versionDir, "manifest.json"),
196
+ JSON.stringify({
197
+ manifest_version: 3,
198
+ description:
199
+ "Connect with Bearer sk_test_super_secret_value at https://user:pass@example.com/path?token=abc#frag",
200
+ version: extensionVersion,
201
+ }),
202
+ "utf-8",
203
+ );
204
+
205
+ const result = collectChromeExtensionsFromPath(versionDir);
206
+
207
+ assert.strictEqual(
208
+ result.components[0].description,
209
+ "Connect with [redacted] at https://example.com/path",
210
+ );
211
+ });
212
+
145
213
  it("should parse real manifest fixtures from Chrome, Chromium and Edge extensions", () => {
146
214
  const fixtureCases = [
147
215
  {
@@ -112,6 +112,34 @@ const CARGO_CACHE_ACTION_PATTERNS = [/^swatinem\/rust-cache(?:@|$)/i];
112
112
 
113
113
  const CARGO_TOOL_INSTALL_ACTION_PATTERNS = [/^taiki-e\/install-action(?:@|$)/i];
114
114
 
115
+ const DEPENDENCY_CACHE_SETUP_ACTIONS = [
116
+ {
117
+ pattern: /^actions\/setup-node(?:@|$)/i,
118
+ ecosystem: "npm",
119
+ inputNames: ["package-manager-cache", "cache"],
120
+ },
121
+ {
122
+ pattern: /^actions\/setup-python(?:@|$)/i,
123
+ ecosystem: "pypi",
124
+ inputNames: ["cache"],
125
+ },
126
+ {
127
+ pattern: /^actions\/setup-go(?:@|$)/i,
128
+ ecosystem: "go",
129
+ inputNames: ["cache"],
130
+ },
131
+ {
132
+ pattern: /^actions\/setup-java(?:@|$)/i,
133
+ ecosystem: "java",
134
+ inputNames: ["cache"],
135
+ },
136
+ {
137
+ pattern: /^moonrepo\/setup-rust(?:@|$)/i,
138
+ ecosystem: "cargo",
139
+ inputNames: ["cache"],
140
+ },
141
+ ];
142
+
115
143
  const FORK_CONTEXT_PATTERNS = [
116
144
  [
117
145
  "github.event.pull_request.head.repo.fork",
@@ -479,6 +507,56 @@ function analyzeCargoActionStep(step) {
479
507
  return props;
480
508
  }
481
509
 
510
+ function isExplicitFalseLikeValue(value) {
511
+ if (value === false) {
512
+ return true;
513
+ }
514
+ if (typeof value !== "string") {
515
+ return false;
516
+ }
517
+ return ["0", "false", "no", "off", "disabled"].includes(
518
+ value.trim().toLowerCase(),
519
+ );
520
+ }
521
+
522
+ function analyzeSetupActionCacheStep(step) {
523
+ const props = [];
524
+ if (!step?.uses || typeof step.uses !== "string") {
525
+ return props;
526
+ }
527
+ const setupAction = DEPENDENCY_CACHE_SETUP_ACTIONS.find((candidate) =>
528
+ candidate.pattern.test(step.uses),
529
+ );
530
+ if (!setupAction || !step.with || typeof step.with !== "object") {
531
+ return props;
532
+ }
533
+ const disableInputName = setupAction.inputNames.find(
534
+ (inputName) =>
535
+ Object.hasOwn(step.with, inputName) &&
536
+ isExplicitFalseLikeValue(step.with[inputName]),
537
+ );
538
+ if (!disableInputName) {
539
+ return props;
540
+ }
541
+ props.push({
542
+ name: "cdx:github:action:disablesBuildCache",
543
+ value: "true",
544
+ });
545
+ props.push({
546
+ name: "cdx:github:action:buildCacheEcosystem",
547
+ value: setupAction.ecosystem,
548
+ });
549
+ props.push({
550
+ name: "cdx:github:action:buildCacheDisableInput",
551
+ value: disableInputName,
552
+ });
553
+ props.push({
554
+ name: "cdx:github:action:buildCacheDisableValue",
555
+ value: String(step.with[disableInputName]),
556
+ });
557
+ return props;
558
+ }
559
+
482
560
  function analyzeCargoRunStep(normalizedRun) {
483
561
  const props = [];
484
562
  if (!normalizedRun || typeof normalizedRun !== "string") {
@@ -2018,6 +2096,7 @@ export function parseWorkflowFile(f, options) {
2018
2096
  actionProperties.push(...analyzeCheckoutStep(step));
2019
2097
  actionProperties.push(...analyzeCacheStep(step));
2020
2098
  actionProperties.push(...analyzeCargoActionStep(step));
2099
+ actionProperties.push(...analyzeSetupActionCacheStep(step));
2021
2100
  actionProperties.push(...analyzeDispatchActionStep(step));
2022
2101
  if (
2023
2102
  step.uses?.includes("actions/github-script") &&
@@ -505,6 +505,109 @@ describe("githubActionsParser", () => {
505
505
  });
506
506
  });
507
507
 
508
+ describe("setup action cache disable property emission", () => {
509
+ it("emits cache disable properties for setup-node, setup-python, and setup-rust", () => {
510
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-cache-"));
511
+ const workflowFile = path.join(tmpDir, "cache-disable.yml");
512
+ writeFileSync(
513
+ workflowFile,
514
+ [
515
+ "name: Cache disable",
516
+ "on: push",
517
+ "jobs:",
518
+ " build:",
519
+ " runs-on: ubuntu-latest",
520
+ " steps:",
521
+ " - uses: actions/setup-node@v4",
522
+ " with:",
523
+ " node-version: 20",
524
+ " package-manager-cache: false",
525
+ " - uses: actions/setup-python@v5",
526
+ " with:",
527
+ " python-version: '3.12'",
528
+ " cache: false",
529
+ " - uses: moonrepo/setup-rust@v1",
530
+ " with:",
531
+ " cache: false",
532
+ ].join("\n"),
533
+ );
534
+
535
+ try {
536
+ const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
537
+ const setupNodeComp = result.components.find(
538
+ (component) =>
539
+ getProp(component, "cdx:github:action:uses") ===
540
+ "actions/setup-node@v4",
541
+ );
542
+ const setupPythonComp = result.components.find(
543
+ (component) =>
544
+ getProp(component, "cdx:github:action:uses") ===
545
+ "actions/setup-python@v5",
546
+ );
547
+ const setupRustComp = result.components.find(
548
+ (component) =>
549
+ getProp(component, "cdx:github:action:uses") ===
550
+ "moonrepo/setup-rust@v1",
551
+ );
552
+ assert.ok(setupNodeComp, "expected setup-node component");
553
+ assert.ok(setupPythonComp, "expected setup-python component");
554
+ assert.ok(setupRustComp, "expected setup-rust component");
555
+ assert.strictEqual(
556
+ getProp(setupNodeComp, "cdx:github:action:disablesBuildCache"),
557
+ "true",
558
+ );
559
+ assert.strictEqual(
560
+ getProp(setupNodeComp, "cdx:github:action:buildCacheEcosystem"),
561
+ "npm",
562
+ );
563
+ assert.strictEqual(
564
+ getProp(setupNodeComp, "cdx:github:action:buildCacheDisableInput"),
565
+ "package-manager-cache",
566
+ );
567
+ assert.strictEqual(
568
+ getProp(setupPythonComp, "cdx:github:action:disablesBuildCache"),
569
+ "true",
570
+ );
571
+ assert.strictEqual(
572
+ getProp(setupPythonComp, "cdx:github:action:buildCacheEcosystem"),
573
+ "pypi",
574
+ );
575
+ assert.strictEqual(
576
+ getProp(setupPythonComp, "cdx:github:action:buildCacheDisableInput"),
577
+ "cache",
578
+ );
579
+ assert.strictEqual(
580
+ getProp(setupRustComp, "cdx:github:action:disablesBuildCache"),
581
+ "true",
582
+ );
583
+ assert.strictEqual(
584
+ getProp(setupRustComp, "cdx:github:action:buildCacheEcosystem"),
585
+ "cargo",
586
+ );
587
+ assert.strictEqual(
588
+ getProp(setupRustComp, "cdx:github:action:buildCacheDisableInput"),
589
+ "cache",
590
+ );
591
+ } finally {
592
+ rmSync(tmpDir, { force: true, recursive: true });
593
+ }
594
+ });
595
+
596
+ it("does not emit cache disable properties when cache is not explicitly disabled", () => {
597
+ const result = parseWorkflow("simple-build.yml");
598
+ const setupNodeComp = result.components.find(
599
+ (component) =>
600
+ getProp(component, "cdx:github:action:uses") ===
601
+ "actions/setup-node@v4",
602
+ );
603
+ assert.ok(setupNodeComp, "expected setup-node component");
604
+ assert.strictEqual(
605
+ getProp(setupNodeComp, "cdx:github:action:disablesBuildCache"),
606
+ undefined,
607
+ );
608
+ });
609
+ });
610
+
508
611
  describe("script injection interpolation detection", () => {
509
612
  it("detects github.event.pull_request interpolation", () => {
510
613
  const result = parseWorkflow("injection-pull-request-title.yml");
@@ -8,6 +8,7 @@ import {
8
8
  credentialIndicatorsForText,
9
9
  sanitizeMcpRefToken,
10
10
  } from "./mcpDiscovery.js";
11
+ import { sanitizeBomPropertyValue } from "./propertySanitizer.js";
11
12
  import { scanTextForHiddenUnicode } from "./unicodeScan.js";
12
13
 
13
14
  const COMMUNITY_AI_PATTERNS = [
@@ -46,13 +47,22 @@ const COMMUNITY_AI_PATTERNS = [
46
47
  ];
47
48
 
48
49
  function addUniqueProperty(properties, name, value) {
49
- if (value === undefined || value === null || value === "") {
50
+ const sanitizedValue = sanitizeBomPropertyValue(name, value);
51
+ if (
52
+ sanitizedValue === undefined ||
53
+ sanitizedValue === null ||
54
+ sanitizedValue === ""
55
+ ) {
50
56
  return;
51
57
  }
52
- if (properties.some((prop) => prop.name === name && prop.value === value)) {
58
+ if (
59
+ properties.some(
60
+ (prop) => prop.name === name && prop.value === String(sanitizedValue),
61
+ )
62
+ ) {
53
63
  return;
54
64
  }
55
- properties.push({ name, value: String(value) });
65
+ properties.push({ name, value: String(sanitizedValue) });
56
66
  }
57
67
 
58
68
  function normalizeFilePath(filePath) {
@@ -217,7 +227,7 @@ function parseSkillFile(filePath, raw) {
217
227
  addUniqueProperty(
218
228
  component.properties,
219
229
  "cdx:skill:metadata",
220
- JSON.stringify(metadata.metadata),
230
+ metadata.metadata,
221
231
  );
222
232
  }
223
233
  maybeAddFileSignals(component.properties, filePath, raw);
@@ -399,7 +409,7 @@ function parseOpencodeConfig(filePath, raw) {
399
409
  addUniqueProperty(
400
410
  component.properties,
401
411
  "cdx:agent:permission",
402
- JSON.stringify(agentConfig.permission),
412
+ agentConfig.permission,
403
413
  );
404
414
  }
405
415
  components.push(component);
@@ -60,4 +60,75 @@ describe("communityAiConfigParser", () => {
60
60
  ),
61
61
  );
62
62
  });
63
+
64
+ it("sanitizes secret-bearing AI inventory properties before emission", async () => {
65
+ const readFileSync = sinon.stub();
66
+ readFileSync.withArgs("/repo/opencode.json", "utf-8").returns(
67
+ JSON.stringify({
68
+ agent: {
69
+ release: {
70
+ description:
71
+ "Deploy with https://user:pass@example.com/release?access_token=abc#frag and sk_test_super_secret_value",
72
+ permission: {
73
+ endpoints: [
74
+ "https://user:pass@example.com/private?token=abc#frag",
75
+ ],
76
+ __proto__: {
77
+ polluted: true,
78
+ },
79
+ },
80
+ },
81
+ },
82
+ }),
83
+ );
84
+ readFileSync
85
+ .withArgs("/repo/.claude/skills/release/SKILL.md", "utf-8")
86
+ .returns(
87
+ [
88
+ "---",
89
+ "name: release",
90
+ "description: Publish release notes",
91
+ "metadata:",
92
+ " endpoint: https://user:pass@example.com/skill?token=abc#frag",
93
+ " apiKey: sk_test_skill_secret_value",
94
+ "---",
95
+ "Use the release workflow.",
96
+ ].join("\n"),
97
+ );
98
+ const { communityAiConfigParser } = await esmock(
99
+ "./communityAiConfigParser.js",
100
+ {
101
+ "node:fs": { readFileSync },
102
+ },
103
+ );
104
+
105
+ const result = communityAiConfigParser.parse([
106
+ "/repo/opencode.json",
107
+ "/repo/.claude/skills/release/SKILL.md",
108
+ ]);
109
+ const agent = result.components.find(
110
+ (component) => getProp(component, "cdx:file:kind") === "agent-config",
111
+ );
112
+ const skill = result.components.find(
113
+ (component) => getProp(component, "cdx:file:kind") === "skill-file",
114
+ );
115
+
116
+ assert.strictEqual(
117
+ getProp(agent, "cdx:agent:description"),
118
+ "Deploy with https://example.com/release and [redacted]",
119
+ );
120
+ assert.strictEqual(
121
+ getProp(agent, "cdx:agent:permission"),
122
+ JSON.stringify({
123
+ endpoints: ["https://example.com/private"],
124
+ }),
125
+ );
126
+ assert.strictEqual(
127
+ getProp(skill, "cdx:skill:metadata"),
128
+ JSON.stringify({
129
+ endpoint: "https://example.com/skill",
130
+ apiKey: "[redacted]",
131
+ }),
132
+ );
133
+ });
63
134
  });
@@ -272,6 +272,11 @@ export function trimComponents(components) {
272
272
  if (!existIdent.methods) {
273
273
  existIdent.methods = [];
274
274
  }
275
+ if (aident.tools?.length) {
276
+ existIdent.tools = Array.from(
277
+ new Set([...(existIdent.tools || []), ...aident.tools]),
278
+ );
279
+ }
275
280
  let isDup = false;
276
281
  for (const emethod of existIdent.methods) {
277
282
  if (emethod?.value === amethod?.value) {
@@ -208,6 +208,61 @@ describe("trimComponents()", () => {
208
208
  { alg: "SHA-256", content: "def456" },
209
209
  ]);
210
210
  });
211
+
212
+ it("retains identity tool references when merging duplicate components", () => {
213
+ const components = [
214
+ {
215
+ name: "openssl",
216
+ version: "3.0.0",
217
+ purl: "pkg:rpm/redhat/openssl@3.0.0",
218
+ type: "library",
219
+ evidence: {
220
+ identity: [
221
+ {
222
+ field: "purl",
223
+ confidence: 1,
224
+ methods: [
225
+ {
226
+ technique: "binary-analysis",
227
+ confidence: 1,
228
+ value: "openssl",
229
+ },
230
+ ],
231
+ tools: ["pkg:generic/trivy@0.1.0"],
232
+ },
233
+ ],
234
+ },
235
+ },
236
+ {
237
+ name: "openssl",
238
+ version: "3.0.0",
239
+ purl: "pkg:rpm/redhat/openssl@3.0.0",
240
+ type: "library",
241
+ evidence: {
242
+ identity: [
243
+ {
244
+ field: "purl",
245
+ confidence: 1,
246
+ methods: [
247
+ {
248
+ technique: "binary-analysis",
249
+ confidence: 1,
250
+ value: "openssl",
251
+ },
252
+ ],
253
+ tools: ["pkg:generic/blint@1.2.3"],
254
+ },
255
+ ],
256
+ },
257
+ },
258
+ ];
259
+ const result = trimComponents(components);
260
+ assert.strictEqual(result.length, 1);
261
+ assert.deepStrictEqual(result[0].evidence.identity[0].tools, [
262
+ "pkg:generic/trivy@0.1.0",
263
+ "pkg:generic/blint@1.2.3",
264
+ ]);
265
+ });
211
266
  });
212
267
 
213
268
  describe("mergeServices()", () => {