@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
@@ -0,0 +1,42 @@
1
+ import esmock from "esmock";
2
+ import { assert, describe, it } from "poku";
3
+ import sinon from "sinon";
4
+
5
+ function getProp(obj, name) {
6
+ return obj?.properties?.find((property) => property.name === name)?.value;
7
+ }
8
+
9
+ describe("agentFormulationParser", () => {
10
+ it("sanitizes inferred MCP URLs before emitting them", async () => {
11
+ const readFileSync = sinon.stub();
12
+ const scanTextForHiddenUnicode = sinon.stub().returns({
13
+ hasHiddenUnicode: false,
14
+ });
15
+ readFileSync
16
+ .withArgs("/repo/AGENTS.md", "utf-8")
17
+ .returns(
18
+ [
19
+ "Use the remote MCP endpoint at",
20
+ "https://user:pass@example.com/mcp?access_token=abc#frag",
21
+ "during release preparation.",
22
+ ].join(" "),
23
+ );
24
+ const { agentFormulationParser } = await esmock(
25
+ "./agentFormulationParser.js",
26
+ {
27
+ "node:fs": { readFileSync },
28
+ "./unicodeScan.js": { scanTextForHiddenUnicode },
29
+ },
30
+ );
31
+
32
+ const result = agentFormulationParser.parse(["/repo/AGENTS.md"]);
33
+
34
+ assert.strictEqual(
35
+ getProp(result.components[0], "cdx:agent:hiddenMcpUrls"),
36
+ "https://example.com/mcp",
37
+ );
38
+ assert.deepStrictEqual(result.services[0].endpoints, [
39
+ "https://example.com/mcp",
40
+ ]);
41
+ });
42
+ });
@@ -8,6 +8,10 @@ import traverse from "@babel/traverse";
8
8
 
9
9
  import { classifyMcpReference } from "./mcp.js";
10
10
  import { isLocalHost, sanitizeMcpRefToken } from "./mcpDiscovery.js";
11
+ import {
12
+ sanitizeBomPropertyValue,
13
+ sanitizeBomUrl,
14
+ } from "./propertySanitizer.js";
11
15
 
12
16
  const IGNORE_DIRS = process.env.ASTGEN_IGNORE_DIRS
13
17
  ? process.env.ASTGEN_IGNORE_DIRS.split(",")
@@ -1509,13 +1513,26 @@ const providerFamilyFromModelName = (modelName) => {
1509
1513
  };
1510
1514
 
1511
1515
  const addUniqueProperty = (properties, name, value) => {
1512
- if (value === undefined || value === null || value === "") {
1516
+ const sanitizedValue = sanitizeBomPropertyValue(name, value);
1517
+ if (
1518
+ sanitizedValue === undefined ||
1519
+ sanitizedValue === null ||
1520
+ sanitizedValue === ""
1521
+ ) {
1513
1522
  return;
1514
1523
  }
1515
- if (properties.some((prop) => prop.name === name && prop.value === value)) {
1524
+ const normalizedValue =
1525
+ typeof sanitizedValue === "string"
1526
+ ? sanitizedValue
1527
+ : String(sanitizedValue);
1528
+ if (
1529
+ properties.some(
1530
+ (prop) => prop.name === name && prop.value === normalizedValue,
1531
+ )
1532
+ ) {
1516
1533
  return;
1517
1534
  }
1518
- properties.push({ name, value });
1535
+ properties.push({ name, value: normalizedValue });
1519
1536
  };
1520
1537
 
1521
1538
  const rootMemberName = (value) => String(value || "").split(".")[0];
@@ -1823,14 +1840,18 @@ const primitiveComponentForMcp = (serviceInfo, primitive) => {
1823
1840
  addUniqueProperty(
1824
1841
  properties,
1825
1842
  "cdx:mcp:toolAnnotations",
1826
- JSON.stringify(primitive.annotations),
1843
+ primitive.annotations,
1827
1844
  );
1828
1845
  }
1829
1846
  return {
1830
1847
  "bom-ref": primitiveRef,
1831
- description:
1832
- primitive.description ||
1833
- `${primitive.role} exposed by ${serviceInfo.name || "mcp-server"}`,
1848
+ description: String(
1849
+ sanitizeBomPropertyValue(
1850
+ "cdx:mcp:description",
1851
+ primitive.description ||
1852
+ `${primitive.role} exposed by ${serviceInfo.name || "mcp-server"}`,
1853
+ ) || "",
1854
+ ),
1834
1855
  name: primitiveName,
1835
1856
  properties,
1836
1857
  scope: "required",
@@ -2056,8 +2077,16 @@ const serviceObjectForMcp = (serviceInfo) => {
2056
2077
  return {
2057
2078
  "bom-ref": serviceRef,
2058
2079
  authenticated: serviceInfo.authenticated,
2059
- description: serviceInfo.description,
2060
- endpoints: Array.from(serviceInfo.endpoints).sort(),
2080
+ description: String(
2081
+ sanitizeBomPropertyValue(
2082
+ "cdx:mcp:description",
2083
+ serviceInfo.description || "",
2084
+ ) || "",
2085
+ ),
2086
+ endpoints: Array.from(serviceInfo.endpoints)
2087
+ .map((endpoint) => sanitizeBomUrl(endpoint))
2088
+ .filter(Boolean)
2089
+ .sort(),
2061
2090
  group: "mcp",
2062
2091
  name: serviceName,
2063
2092
  properties,
@@ -7,6 +7,7 @@ import {
7
7
  } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
+ import { URL } from "node:url";
10
11
 
11
12
  import { assert, describe, it } from "poku";
12
13
 
@@ -530,6 +531,72 @@ describe("detectMcpInventory()", () => {
530
531
  ),
531
532
  );
532
533
  });
534
+
535
+ it("sanitizes source-code-analysis MCP metadata before emission", () => {
536
+ const projectDir = createProjectFiles("mcp-sanitized-source-analysis", {
537
+ "src/server.ts": [
538
+ "import { McpServer } from '@modelcontextprotocol/server';",
539
+ "import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';",
540
+ "const server = new McpServer({",
541
+ " name: 'sanitized-server',",
542
+ " version: '0.3.0',",
543
+ " description: 'Use https://user:pass@example.com/mcp?token=abc#frag and Bearer sk_test_super_secret_value',",
544
+ "});",
545
+ "server.registerTool(",
546
+ " 'download',",
547
+ " {",
548
+ " description: 'Download from https://user:pass@example.com/tool?token=abc#frag',",
549
+ " annotations: {",
550
+ " Authorization: 'Bearer sk_test_super_secret_value',",
551
+ " nested: { __proto__: 'polluted', endpoint: 'https://user:pass@example.com/tool?token=abc#frag' },",
552
+ " },",
553
+ " },",
554
+ " async () => ({ content: [] }),",
555
+ ");",
556
+ "server.registerResource(",
557
+ " 'private-docs',",
558
+ " 'https://user:pass@example.com/docs?token=abc#frag',",
559
+ " { description: 'Private docs' },",
560
+ " async () => ({ contents: [] }),",
561
+ ");",
562
+ "const transport = new StreamableHTTPClientTransport(new URL('https://user:pass@example.com/mcp?access_token=secret#frag'));",
563
+ "void transport;",
564
+ ].join("\n"),
565
+ });
566
+
567
+ const inventory = detectMcpInventory(projectDir);
568
+ const service = inventory.services[0];
569
+ const toolComponent = inventory.components.find(
570
+ (component) => component.name === "download",
571
+ );
572
+ const resourceComponent = inventory.components.find(
573
+ (component) => component.name === "private-docs",
574
+ );
575
+
576
+ assert.strictEqual(
577
+ service.description,
578
+ "Use https://example.com/mcp and [redacted]",
579
+ );
580
+ const serviceEndpoint = new URL(service.endpoints[0]);
581
+ assert.strictEqual(serviceEndpoint.hostname, "example.com");
582
+ assert.strictEqual(serviceEndpoint.pathname, "/mcp");
583
+ assert.strictEqual(
584
+ getProp(resourceComponent, "cdx:mcp:resourceUri"),
585
+ "https://example.com/docs",
586
+ );
587
+ assert.strictEqual(
588
+ toolComponent.description,
589
+ "Download from https://example.com/tool",
590
+ );
591
+ const toolAnnotations = JSON.parse(
592
+ getProp(toolComponent, "cdx:mcp:toolAnnotations"),
593
+ );
594
+ assert.strictEqual(toolAnnotations.Authorization, "[redacted]");
595
+ assert.ok(
596
+ !JSON.stringify(toolAnnotations).includes("sk_test_super_secret_value"),
597
+ );
598
+ assert.ok(!JSON.stringify(toolAnnotations).includes("__proto__"));
599
+ });
533
600
  });
534
601
 
535
602
  describe("detectPythonMcpInventory()", () => {
@@ -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) {