@cyclonedx/cdxgen 12.3.0 → 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 (121) hide show
  1. package/README.md +15 -5
  2. package/bin/audit.js +7 -0
  3. package/bin/cdxgen.js +241 -81
  4. package/bin/repl.js +138 -0
  5. package/data/rules/ai-agent-governance.yaml +249 -0
  6. package/data/rules/dependency-sources.yaml +41 -0
  7. package/data/rules/mcp-servers.yaml +304 -0
  8. package/data/rules/package-integrity.yaml +123 -0
  9. package/lib/audit/index.js +353 -29
  10. package/lib/audit/index.poku.js +247 -7
  11. package/lib/audit/reporters.js +26 -0
  12. package/lib/audit/scoring.js +262 -13
  13. package/lib/audit/scoring.poku.js +179 -0
  14. package/lib/audit/targets.js +391 -2
  15. package/lib/audit/targets.poku.js +416 -3
  16. package/lib/cli/index.js +588 -45
  17. package/lib/cli/index.poku.js +735 -1
  18. package/lib/evinser/evinser.js +8 -5
  19. package/lib/helpers/agentFormulationParser.js +318 -0
  20. package/lib/helpers/aiInventory.js +262 -0
  21. package/lib/helpers/aiInventory.poku.js +111 -0
  22. package/lib/helpers/analyzer.js +1769 -0
  23. package/lib/helpers/analyzer.poku.js +284 -3
  24. package/lib/helpers/auditCategories.js +76 -0
  25. package/lib/helpers/ciParsers/githubActions.js +140 -16
  26. package/lib/helpers/ciParsers/githubActions.poku.js +110 -0
  27. package/lib/helpers/communityAiConfigParser.js +672 -0
  28. package/lib/helpers/communityAiConfigParser.poku.js +63 -0
  29. package/lib/helpers/depsUtils.js +108 -0
  30. package/lib/helpers/depsUtils.poku.js +72 -1
  31. package/lib/helpers/display.js +325 -3
  32. package/lib/helpers/display.poku.js +301 -0
  33. package/lib/helpers/formulationParsers.js +28 -0
  34. package/lib/helpers/formulationParsers.poku.js +504 -1
  35. package/lib/helpers/jsonLike.js +102 -0
  36. package/lib/helpers/jsonLike.poku.js +34 -0
  37. package/lib/helpers/mcp.js +248 -0
  38. package/lib/helpers/mcp.poku.js +101 -0
  39. package/lib/helpers/mcpConfigParser.js +656 -0
  40. package/lib/helpers/mcpConfigParser.poku.js +126 -0
  41. package/lib/helpers/mcpDiscovery.js +84 -0
  42. package/lib/helpers/mcpDiscovery.poku.js +21 -0
  43. package/lib/helpers/protobom.js +3 -3
  44. package/lib/helpers/provenanceUtils.js +29 -4
  45. package/lib/helpers/provenanceUtils.poku.js +29 -3
  46. package/lib/helpers/registryProvenance.js +210 -0
  47. package/lib/helpers/registryProvenance.poku.js +144 -0
  48. package/lib/helpers/rustFormulationParser.js +330 -0
  49. package/lib/helpers/source.js +21 -2
  50. package/lib/helpers/source.poku.js +38 -0
  51. package/lib/helpers/utils.js +1331 -83
  52. package/lib/helpers/utils.poku.js +599 -188
  53. package/lib/helpers/vsixutils.js +12 -4
  54. package/lib/helpers/vsixutils.poku.js +34 -0
  55. package/lib/managers/binary.js +36 -12
  56. package/lib/managers/binary.poku.js +68 -0
  57. package/lib/managers/docker.js +59 -9
  58. package/lib/managers/docker.poku.js +61 -0
  59. package/lib/managers/piptree.js +12 -7
  60. package/lib/managers/piptree.poku.js +44 -0
  61. package/lib/stages/postgen/annotator.js +2 -1
  62. package/lib/stages/postgen/annotator.poku.js +15 -0
  63. package/lib/stages/postgen/auditBom.js +20 -6
  64. package/lib/stages/postgen/auditBom.poku.js +694 -1
  65. package/lib/stages/postgen/postgen.js +262 -11
  66. package/lib/stages/postgen/postgen.poku.js +306 -2
  67. package/lib/stages/postgen/ruleEngine.js +49 -1
  68. package/lib/stages/postgen/spdxConverter.poku.js +70 -0
  69. package/lib/stages/pregen/pregen.js +6 -4
  70. package/package.json +1 -1
  71. package/types/bin/repl.d.ts.map +1 -1
  72. package/types/lib/audit/index.d.ts.map +1 -1
  73. package/types/lib/audit/reporters.d.ts.map +1 -1
  74. package/types/lib/audit/scoring.d.ts.map +1 -1
  75. package/types/lib/audit/targets.d.ts +12 -0
  76. package/types/lib/audit/targets.d.ts.map +1 -1
  77. package/types/lib/cli/index.d.ts +2 -8
  78. package/types/lib/cli/index.d.ts.map +1 -1
  79. package/types/lib/evinser/evinser.d.ts.map +1 -1
  80. package/types/lib/helpers/agentFormulationParser.d.ts +19 -0
  81. package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -0
  82. package/types/lib/helpers/aiInventory.d.ts +23 -0
  83. package/types/lib/helpers/aiInventory.d.ts.map +1 -0
  84. package/types/lib/helpers/analyzer.d.ts +10 -0
  85. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  86. package/types/lib/helpers/auditCategories.d.ts +12 -0
  87. package/types/lib/helpers/auditCategories.d.ts.map +1 -0
  88. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  89. package/types/lib/helpers/communityAiConfigParser.d.ts +29 -0
  90. package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -0
  91. package/types/lib/helpers/depsUtils.d.ts +8 -0
  92. package/types/lib/helpers/depsUtils.d.ts.map +1 -1
  93. package/types/lib/helpers/display.d.ts +17 -1
  94. package/types/lib/helpers/display.d.ts.map +1 -1
  95. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  96. package/types/lib/helpers/jsonLike.d.ts +4 -0
  97. package/types/lib/helpers/jsonLike.d.ts.map +1 -0
  98. package/types/lib/helpers/mcp.d.ts +29 -0
  99. package/types/lib/helpers/mcp.d.ts.map +1 -0
  100. package/types/lib/helpers/mcpConfigParser.d.ts +30 -0
  101. package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -0
  102. package/types/lib/helpers/mcpDiscovery.d.ts +5 -0
  103. package/types/lib/helpers/mcpDiscovery.d.ts.map +1 -0
  104. package/types/lib/helpers/provenanceUtils.d.ts +5 -3
  105. package/types/lib/helpers/provenanceUtils.d.ts.map +1 -1
  106. package/types/lib/helpers/registryProvenance.d.ts +9 -0
  107. package/types/lib/helpers/registryProvenance.d.ts.map +1 -1
  108. package/types/lib/helpers/rustFormulationParser.d.ts +17 -0
  109. package/types/lib/helpers/rustFormulationParser.d.ts.map +1 -0
  110. package/types/lib/helpers/source.d.ts.map +1 -1
  111. package/types/lib/helpers/utils.d.ts +31 -1
  112. package/types/lib/helpers/utils.d.ts.map +1 -1
  113. package/types/lib/helpers/vsixutils.d.ts.map +1 -1
  114. package/types/lib/managers/binary.d.ts.map +1 -1
  115. package/types/lib/managers/docker.d.ts.map +1 -1
  116. package/types/lib/managers/piptree.d.ts.map +1 -1
  117. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  118. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  119. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  120. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  121. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
@@ -0,0 +1,126 @@
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("mcpConfigParser", () => {
10
+ it("normalizes Windows paths for config format detection and treats jsonc as json", async () => {
11
+ const readFileSync = sinon.stub();
12
+ const scanTextForHiddenUnicode = sinon.stub().returns({
13
+ hasHiddenUnicode: false,
14
+ });
15
+ readFileSync.withArgs("C:\\repo\\.vscode\\mcp.json", "utf-8").returns(
16
+ JSON.stringify({
17
+ mcpServers: {
18
+ localDocs: {
19
+ transport: "streamable-http",
20
+ url: "https://docs.example.com/mcp",
21
+ },
22
+ },
23
+ }),
24
+ );
25
+ readFileSync.withArgs("C:\\repo\\opencode.jsonc", "utf-8").returns(`{
26
+ // JSONC config
27
+ "mcp": {
28
+ "remoteDocs": {
29
+ "type": "remote",
30
+ "url": "https://example.com/mcp"
31
+ }
32
+ }
33
+ }`);
34
+ const { mcpConfigParser } = await esmock("./mcpConfigParser.js", {
35
+ "node:fs": { readFileSync },
36
+ "./unicodeScan.js": { scanTextForHiddenUnicode },
37
+ });
38
+
39
+ const result = mcpConfigParser.parse([
40
+ "C:\\repo\\.vscode\\mcp.json",
41
+ "C:\\repo\\opencode.jsonc",
42
+ ]);
43
+
44
+ assert.ok(
45
+ result.components.some(
46
+ (component) => getProp(component, "cdx:mcp:configFormat") === "vscode",
47
+ ),
48
+ );
49
+ assert.ok(
50
+ result.components.some(
51
+ (component) =>
52
+ getProp(component, "cdx:mcp:configFormat") === "opencode",
53
+ ),
54
+ );
55
+ sinon.assert.calledWithMatch(scanTextForHiddenUnicode, sinon.match.string, {
56
+ syntax: "json",
57
+ });
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
+ });
126
+ });
@@ -0,0 +1,84 @@
1
+ const PROVIDER_TEXT_PATTERNS = [
2
+ ["anthropic", /\banthropic\b|claude/i],
3
+ ["openai", /\bopenai\b|\bgpt-[a-z0-9-]+\b|\bo[13]\b/i],
4
+ ["google", /\bgemini\b|google(?:\s+ai)?/i],
5
+ ["mistral", /\bmistral\b/i],
6
+ ["deepseek", /\bdeepseek\b/i],
7
+ ["ollama", /\bollama\b/i],
8
+ ["groq", /\bgroq\b/i],
9
+ ];
10
+
11
+ const INLINE_CREDENTIAL_PATTERNS = [
12
+ ["aws-access-key", /\bAKIA[0-9A-Z]{16}\b/u],
13
+ ["bearer-token", /\bbearer\s+[a-z0-9._-]{16,}\b/iu],
14
+ ["generic-secret", /\b(?:sk|rk|pk)_[a-z0-9_-]{8,}\b/iu],
15
+ ["github-token", /\bgh[pousr]_[a-z0-9]{20,}\b/iu],
16
+ ["google-api-key", /\bAIza[0-9A-Za-z_-]{20,}\b/u],
17
+ ];
18
+
19
+ export function sanitizeMcpRefToken(value) {
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";
32
+ }
33
+ return normalized.slice(0, 128);
34
+ }
35
+
36
+ export function isLocalHost(hostname) {
37
+ const normalized = String(hostname || "").toLowerCase();
38
+ if (
39
+ !normalized ||
40
+ normalized === "localhost" ||
41
+ normalized === "127.0.0.1" ||
42
+ normalized === "::1"
43
+ ) {
44
+ return true;
45
+ }
46
+ if (
47
+ normalized.startsWith("10.") ||
48
+ normalized.startsWith("127.") ||
49
+ normalized.startsWith("169.254.") ||
50
+ normalized.startsWith("192.168.")
51
+ ) {
52
+ return true;
53
+ }
54
+ const octets = normalized.split(".");
55
+ if (
56
+ octets.length === 4 &&
57
+ octets[0] === "172" &&
58
+ Number(octets[1]) >= 16 &&
59
+ Number(octets[1]) <= 31
60
+ ) {
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ export function providerNamesForText(text) {
67
+ return [
68
+ ...new Set(
69
+ PROVIDER_TEXT_PATTERNS.flatMap(([name, pattern]) =>
70
+ pattern.test(text) ? [name] : [],
71
+ ),
72
+ ),
73
+ ];
74
+ }
75
+
76
+ export function credentialIndicatorsForText(text) {
77
+ return [
78
+ ...new Set(
79
+ INLINE_CREDENTIAL_PATTERNS.flatMap(([name, pattern]) =>
80
+ pattern.test(text) ? [name] : [],
81
+ ),
82
+ ),
83
+ ];
84
+ }
@@ -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
+ });
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
2
 
3
3
  import { cdx_16, cdx_17 } from "@appthreat/cdx-proto";
4
4
  import {
@@ -8,7 +8,7 @@ import {
8
8
  toJson,
9
9
  } from "@bufbuild/protobuf";
10
10
 
11
- import { safeExistsSync } from "./utils.js";
11
+ import { safeExistsSync, safeWriteSync } from "./utils.js";
12
12
 
13
13
  /**
14
14
  * Stringify the given bom json based on the type.
@@ -37,7 +37,7 @@ export const writeBinary = (bomJson, binFile) => {
37
37
  } else {
38
38
  bomSchema = cdx_16.BomSchema;
39
39
  }
40
- writeFileSync(
40
+ safeWriteSync(
41
41
  binFile,
42
42
  toBinary(
43
43
  bomSchema,
@@ -2,6 +2,8 @@ const NPM_PROVENANCE_URL_PROPERTY = "cdx:npm:provenanceUrl";
2
2
  const NPM_TRUSTED_PUBLISHING_PROPERTY = "cdx:npm:trustedPublishing";
3
3
  const PYPI_PROVENANCE_URL_PROPERTY = "cdx:pypi:provenanceUrl";
4
4
  const PYPI_TRUSTED_PUBLISHING_PROPERTY = "cdx:pypi:trustedPublishing";
5
+ const CARGO_PROVENANCE_URL_PROPERTY = "cdx:cargo:provenanceUrl";
6
+ const CARGO_TRUSTED_PUBLISHING_PROPERTY = "cdx:cargo:trustedPublishing";
5
7
 
6
8
  export const NPM_PROVENANCE_EVIDENCE_PROPERTIES = [
7
9
  NPM_PROVENANCE_URL_PROPERTY,
@@ -22,13 +24,23 @@ export const PYPI_PROVENANCE_EVIDENCE_PROPERTIES = [
22
24
  "cdx:pypi:artifactDigestBlake2b256",
23
25
  "cdx:pypi:artifactDigestMd5",
24
26
  ];
27
+ export const CARGO_PROVENANCE_EVIDENCE_PROPERTIES = [
28
+ CARGO_PROVENANCE_URL_PROPERTY,
29
+ "cdx:cargo:provenanceDigest",
30
+ "cdx:cargo:provenanceKeyId",
31
+ "cdx:cargo:provenancePredicateType",
32
+ "cdx:cargo:provenanceSignature",
33
+ "cdx:cargo:artifactDigestSha256",
34
+ ];
25
35
  export const REGISTRY_PROVENANCE_EVIDENCE_PROPERTIES = [
26
36
  ...NPM_PROVENANCE_EVIDENCE_PROPERTIES,
27
37
  ...PYPI_PROVENANCE_EVIDENCE_PROPERTIES,
38
+ ...CARGO_PROVENANCE_EVIDENCE_PROPERTIES,
28
39
  ];
29
40
  export const TRUSTED_PUBLISHING_PROPERTIES = [
30
41
  NPM_TRUSTED_PUBLISHING_PROPERTY,
31
42
  PYPI_TRUSTED_PUBLISHING_PROPERTY,
43
+ CARGO_TRUSTED_PUBLISHING_PROPERTY,
32
44
  ];
33
45
 
34
46
  export const REGISTRY_PROVENANCE_ICON = "🛡";
@@ -73,7 +85,7 @@ export function hasAnyPropertyValue(properties, propertyNames) {
73
85
  * Determine whether a raw properties array includes trusted publishing metadata.
74
86
  *
75
87
  * @param {object[]} properties CycloneDX properties array
76
- * @returns {boolean} True when trusted publishing is recorded for npm or PyPI
88
+ * @returns {boolean} True when trusted publishing is recorded for npm, PyPI, or Cargo
77
89
  */
78
90
  export function hasTrustedPublishingProperties(properties) {
79
91
  return TRUSTED_PUBLISHING_PROPERTIES.some(
@@ -98,7 +110,7 @@ export function hasRegistryProvenanceEvidenceProperties(properties) {
98
110
  * Determine whether a component includes trusted publishing metadata.
99
111
  *
100
112
  * @param {object} component CycloneDX component
101
- * @returns {boolean} True when trusted publishing is recorded for npm or PyPI
113
+ * @returns {boolean} True when trusted publishing is recorded for npm, PyPI, or Cargo
102
114
  */
103
115
  export function hasComponentTrustedPublishing(component) {
104
116
  return hasTrustedPublishingProperties(component?.properties);
@@ -161,10 +173,11 @@ export function getProvenanceComponents(components) {
161
173
  * Count components with trusted publishing metadata by registry ecosystem.
162
174
  *
163
175
  * @param {object[]} components BOM components
164
- * @returns {{npm: number, pypi: number, total: number}} Trusted publishing counts
176
+ * @returns {{cargo: number, npm: number, pypi: number, total: number}} Trusted publishing counts
165
177
  */
166
178
  export function getTrustedPublishingComponentCounts(components) {
167
179
  const counts = {
180
+ cargo: 0,
168
181
  npm: 0,
169
182
  pypi: 0,
170
183
  total: 0,
@@ -179,13 +192,25 @@ export function getTrustedPublishingComponentCounts(components) {
179
192
  const pypiTrustedPublishing =
180
193
  getComponentPropertyValue(component, PYPI_TRUSTED_PUBLISHING_PROPERTY) ===
181
194
  "true";
195
+ const cargoTrustedPublishing =
196
+ getComponentPropertyValue(
197
+ component,
198
+ CARGO_TRUSTED_PUBLISHING_PROPERTY,
199
+ ) === "true";
182
200
  if (npmTrustedPublishing) {
183
201
  counts.npm += 1;
184
202
  }
185
203
  if (pypiTrustedPublishing) {
186
204
  counts.pypi += 1;
187
205
  }
188
- if (npmTrustedPublishing || pypiTrustedPublishing) {
206
+ if (cargoTrustedPublishing) {
207
+ counts.cargo += 1;
208
+ }
209
+ if (
210
+ npmTrustedPublishing ||
211
+ pypiTrustedPublishing ||
212
+ cargoTrustedPublishing
213
+ ) {
189
214
  counts.total += 1;
190
215
  }
191
216
  }
@@ -32,6 +32,19 @@ describe("provenanceUtils", () => {
32
32
  },
33
33
  ],
34
34
  };
35
+ const cargoTrustedComponent = {
36
+ name: "serde",
37
+ properties: [
38
+ {
39
+ name: "cdx:cargo:trustedPublishing",
40
+ value: "true",
41
+ },
42
+ {
43
+ name: "cdx:cargo:provenanceUrl",
44
+ value: "https://crates.io/provenance/serde/1.0.0",
45
+ },
46
+ ],
47
+ };
35
48
  const plainComponent = {
36
49
  name: "lodash",
37
50
  properties: [],
@@ -58,17 +71,19 @@ describe("provenanceUtils", () => {
58
71
  getTrustedComponents([
59
72
  plainComponent,
60
73
  npmTrustedComponent,
74
+ cargoTrustedComponent,
61
75
  pypiProvenanceComponent,
62
76
  ]).map((component) => component.name),
63
- ["left-pad"],
77
+ ["left-pad", "serde"],
64
78
  );
65
79
  assert.deepStrictEqual(
66
80
  getProvenanceComponents([
67
81
  plainComponent,
68
82
  npmTrustedComponent,
83
+ cargoTrustedComponent,
69
84
  pypiProvenanceComponent,
70
85
  ]).map((component) => component.name),
71
- ["requests"],
86
+ ["serde", "requests"],
72
87
  );
73
88
  assert.deepStrictEqual(
74
89
  getTrustedPublishingComponentCounts([
@@ -82,12 +97,14 @@ describe("provenanceUtils", () => {
82
97
  },
83
98
  ],
84
99
  },
100
+ cargoTrustedComponent,
85
101
  plainComponent,
86
102
  ]),
87
103
  {
104
+ cargo: 1,
88
105
  npm: 1,
89
106
  pypi: 1,
90
- total: 2,
107
+ total: 3,
91
108
  },
92
109
  );
93
110
  });
@@ -102,6 +119,10 @@ describe("provenanceUtils", () => {
102
119
  name: "cdx:npm:trustedPublishing",
103
120
  value: "true",
104
121
  },
122
+ {
123
+ name: "cdx:cargo:artifactDigestSha256",
124
+ value: "deadbeef",
125
+ },
105
126
  ];
106
127
  assert.strictEqual(
107
128
  getPropertyValue(properties, "cdx:npm:provenanceKeyId"),
@@ -132,10 +153,15 @@ describe("provenanceUtils", () => {
132
153
  name: "cdx:pypi:trustedPublishing",
133
154
  value: "true",
134
155
  },
156
+ {
157
+ name: "cdx:cargo:trustedPublishing",
158
+ value: "true",
159
+ },
135
160
  ],
136
161
  },
137
162
  ]),
138
163
  {
164
+ cargo: 1,
139
165
  npm: 1,
140
166
  pypi: 1,
141
167
  total: 1,
@@ -791,3 +791,213 @@ export function collectPypiRegistryProvenanceProperties(projectBody, version) {
791
791
  );
792
792
  return properties;
793
793
  }
794
+
795
+ /**
796
+ * Extract Cargo/crates.io release, publisher, and provenance-adjacent properties.
797
+ *
798
+ * @param {object} crateBody crates.io `/api/v1/crates/{name}` response body
799
+ * @param {string | undefined} version crate version
800
+ * @param {object} [ownersBody] crates.io `/api/v1/crates/{name}/owners` response body
801
+ * @returns {object[]} custom properties
802
+ */
803
+ export function collectCargoRegistryProvenanceProperties(
804
+ crateBody,
805
+ version,
806
+ ownersBody,
807
+ ) {
808
+ const properties = [];
809
+ const versions = Array.isArray(crateBody?.versions) ? crateBody.versions : [];
810
+ const currentVersionBody =
811
+ versions.find((entry) => entry?.num === version) || versions[0];
812
+ if (!currentVersionBody) {
813
+ return properties;
814
+ }
815
+ const releaseEntries = versions
816
+ .map((entry) => ({
817
+ publishers: uniqueStrings([
818
+ entry?.published_by?.login,
819
+ entry?.published_by?.name,
820
+ ]),
821
+ timestamp: parseTimestamp(entry?.created_at || entry?.updated_at),
822
+ version: entry?.num,
823
+ rawTime: entry?.created_at || entry?.updated_at,
824
+ }))
825
+ .filter((entry) => entry.version && entry.timestamp !== undefined);
826
+ const currentPublishTimestamp = parseTimestamp(
827
+ currentVersionBody?.created_at || currentVersionBody?.updated_at,
828
+ );
829
+ const priorReleaseEntry = sortReleaseEntries(
830
+ releaseEntries.filter(
831
+ (entry) =>
832
+ entry.version !== currentVersionBody?.num &&
833
+ currentPublishTimestamp !== undefined &&
834
+ entry.timestamp < currentPublishTimestamp,
835
+ ),
836
+ ).pop();
837
+ const gapMetrics = releaseGapMetrics(releaseEntries, currentVersionBody?.num);
838
+ const cadenceMetrics = compressedCadenceMetrics(gapMetrics);
839
+ const currentPublisherSet = uniqueIdentities([
840
+ currentVersionBody?.published_by?.login,
841
+ currentVersionBody?.published_by?.name,
842
+ ]);
843
+ const priorPublisherSet = uniqueIdentities(
844
+ priorReleaseEntry?.publishers || [],
845
+ );
846
+ const overlapMetrics = identityOverlapMetrics(
847
+ currentPublisherSet,
848
+ priorPublisherSet,
849
+ );
850
+ const publisherDrift = isDisjointIdentitySet(
851
+ currentPublisherSet,
852
+ priorPublisherSet,
853
+ );
854
+ const ownerSet = uniqueIdentities(
855
+ (ownersBody?.users || []).flatMap((owner) => [owner?.login, owner?.name]),
856
+ );
857
+ const trustpubCandidate =
858
+ currentVersionBody?.trustpub_data ||
859
+ currentVersionBody?.trustpubData ||
860
+ crateBody?.crate?.trustpub_data ||
861
+ crateBody?.crate?.trustpubData ||
862
+ crateBody?.versions?.find((entry) => entry?.trustpub_data)?.trustpub_data;
863
+ const provenanceUrl = normalizeProvenanceUrl(trustpubCandidate);
864
+ const provenanceDigests = collectProvenanceDigests(trustpubCandidate);
865
+ const provenanceKeyIds = collectProvenanceKeyIds(trustpubCandidate);
866
+ const provenanceSignatures = collectProvenanceSignatures(trustpubCandidate);
867
+ const provenancePredicateTypes =
868
+ collectProvenancePredicateTypes(trustpubCandidate);
869
+
870
+ appendProperty(
871
+ properties,
872
+ "cdx:cargo:packageCreatedTime",
873
+ sortReleaseEntries([...releaseEntries])[0]?.rawTime,
874
+ );
875
+ appendProperty(
876
+ properties,
877
+ "cdx:cargo:publishTime",
878
+ currentVersionBody?.created_at || currentVersionBody?.updated_at,
879
+ );
880
+ appendProperty(properties, "cdx:cargo:versionCount", releaseEntries.length);
881
+ appendProperty(
882
+ properties,
883
+ "cdx:cargo:publisher",
884
+ currentVersionBody?.published_by?.login ||
885
+ currentVersionBody?.published_by?.name,
886
+ );
887
+ appendProperty(
888
+ properties,
889
+ "cdx:cargo:priorPublisher",
890
+ priorReleaseEntry?.publishers?.join(", "),
891
+ );
892
+ appendProperty(
893
+ properties,
894
+ "cdx:cargo:publisherSet",
895
+ currentPublisherSet.join(", "),
896
+ );
897
+ appendProperty(
898
+ properties,
899
+ "cdx:cargo:publisherSetCount",
900
+ currentPublisherSet.length,
901
+ );
902
+ appendProperty(properties, "cdx:cargo:ownerSet", ownerSet.join(", "));
903
+ appendProperty(properties, "cdx:cargo:ownerSetCount", ownerSet.length);
904
+ appendProperty(
905
+ properties,
906
+ "cdx:cargo:publisherOverlapCount",
907
+ overlapMetrics.overlapCount,
908
+ );
909
+ appendProperty(
910
+ properties,
911
+ "cdx:cargo:publisherOverlapRatio",
912
+ overlapMetrics.overlapRatio?.toFixed(2),
913
+ );
914
+ appendProperty(
915
+ properties,
916
+ "cdx:cargo:priorVersion",
917
+ priorReleaseEntry?.version,
918
+ );
919
+ appendProperty(
920
+ properties,
921
+ "cdx:cargo:priorPublishTime",
922
+ priorReleaseEntry?.rawTime,
923
+ );
924
+ appendProperty(
925
+ properties,
926
+ "cdx:cargo:releaseGapDays",
927
+ gapMetrics.currentGapDays?.toFixed(2),
928
+ );
929
+ appendProperty(
930
+ properties,
931
+ "cdx:cargo:releaseGapBaselineDays",
932
+ gapMetrics.baselineDays?.toFixed(2),
933
+ );
934
+ appendProperty(
935
+ properties,
936
+ "cdx:cargo:releaseGapSampleSize",
937
+ gapMetrics.sampleSize,
938
+ );
939
+ appendProperty(
940
+ properties,
941
+ "cdx:cargo:releaseCadenceCompressionRatio",
942
+ cadenceMetrics.compressionRatio?.toFixed(2),
943
+ );
944
+ appendProperty(
945
+ properties,
946
+ "cdx:cargo:artifactDigestSha256",
947
+ currentVersionBody?.checksum,
948
+ );
949
+ appendProperty(properties, "cdx:cargo:edition", currentVersionBody?.edition);
950
+ appendProperty(properties, "cdx:cargo:hasLib", currentVersionBody?.has_lib);
951
+ appendJoinedProperty(
952
+ properties,
953
+ "cdx:cargo:binNames",
954
+ Array.isArray(currentVersionBody?.bin_names)
955
+ ? currentVersionBody.bin_names
956
+ : [],
957
+ );
958
+ appendProperty(
959
+ properties,
960
+ "cdx:cargo:crateSize",
961
+ currentVersionBody?.crate_size,
962
+ );
963
+ if (currentVersionBody?.yanked === true) {
964
+ appendProperty(properties, "cdx:cargo:yanked", "true");
965
+ }
966
+ if (publisherDrift) {
967
+ appendProperty(properties, "cdx:cargo:publisherDrift", "true");
968
+ }
969
+ if (overlapMetrics.partialDrift) {
970
+ appendProperty(properties, "cdx:cargo:publisherSetPartialDrift", "true");
971
+ }
972
+ if (cadenceMetrics.compressedCadence) {
973
+ appendProperty(properties, "cdx:cargo:compressedCadence", "true");
974
+ }
975
+ if (
976
+ crateBody?.crate?.trustpub_only === true ||
977
+ hasTrustedPublishingEvidence(trustpubCandidate)
978
+ ) {
979
+ appendProperty(properties, "cdx:cargo:trustedPublishing", "true");
980
+ }
981
+ appendProperty(properties, "cdx:cargo:provenanceUrl", provenanceUrl);
982
+ appendJoinedProperty(
983
+ properties,
984
+ "cdx:cargo:provenanceDigest",
985
+ provenanceDigests,
986
+ );
987
+ appendJoinedProperty(
988
+ properties,
989
+ "cdx:cargo:provenanceKeyId",
990
+ provenanceKeyIds,
991
+ );
992
+ appendJoinedProperty(
993
+ properties,
994
+ "cdx:cargo:provenanceSignature",
995
+ provenanceSignatures,
996
+ );
997
+ appendJoinedProperty(
998
+ properties,
999
+ "cdx:cargo:provenancePredicateType",
1000
+ provenancePredicateTypes,
1001
+ );
1002
+ return properties;
1003
+ }