@cdxgen/cdxgen 13.1.0 → 13.2.0

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 (186) hide show
  1. package/README.md +8 -7
  2. package/bin/cdxgen.js +16 -3
  3. package/bin/evinse.js +2 -0
  4. package/data/crypto-oid.json +88 -12
  5. package/data/remediations.json +23 -0
  6. package/data/rules/ai-bom.yaml +78 -0
  7. package/data/rules/cargo-rust.yaml +132 -0
  8. package/data/rules/mcp-servers.yaml +122 -0
  9. package/lib/audit/targets.js +6 -1
  10. package/lib/cli/bomAssembly.js +12 -0
  11. package/lib/cli/index.js +192 -1
  12. package/lib/cli/jsBom.js +59 -2
  13. package/lib/cli/jvmBom.js +22 -0
  14. package/lib/cli/managedBom.js +152 -0
  15. package/lib/cli/nativeBom.js +798 -1
  16. package/lib/core/activity.js +120 -2
  17. package/lib/core/env.js +39 -1
  18. package/lib/core/userAgent.js +151 -0
  19. package/lib/ecosystems/cargoCache.js +364 -0
  20. package/lib/ecosystems/caxa.js +13 -5
  21. package/lib/ecosystems/cppEvidence.js +40 -2
  22. package/lib/ecosystems/ecosystems.js +241 -91
  23. package/lib/ecosystems/golem.js +11 -1
  24. package/lib/ecosystems/gradleutils.js +143 -2
  25. package/lib/ecosystems/kosi.js +1256 -0
  26. package/lib/ecosystems/parsers-crystal.js +179 -0
  27. package/lib/ecosystems/parsers-dotnet.js +69 -39
  28. package/lib/ecosystems/parsers-elm.js +816 -0
  29. package/lib/ecosystems/parsers-gleam.js +1 -1
  30. package/lib/ecosystems/parsers-julia.js +268 -0
  31. package/lib/ecosystems/parsers-lua.js +218 -0
  32. package/lib/ecosystems/parsers-misc.js +172 -0
  33. package/lib/ecosystems/parsers-nim.js +221 -0
  34. package/lib/ecosystems/parsers-opam.js +180 -0
  35. package/lib/ecosystems/parsers-perl.js +277 -0
  36. package/lib/ecosystems/parsers-python.js +1 -1
  37. package/lib/ecosystems/parsers-r.js +148 -0
  38. package/lib/ecosystems/parsers-rebar.js +411 -0
  39. package/lib/ecosystems/parsers-rust.js +932 -17
  40. package/lib/ecosystems/parsers-spack.js +204 -0
  41. package/lib/ecosystems/parsers-stack.js +264 -0
  42. package/lib/ecosystems/parsers-terraform.js +194 -0
  43. package/lib/ecosystems/rusi.js +126 -6
  44. package/lib/evinser/evinser.js +111 -1
  45. package/lib/helpers/agenticToolutils.js +925 -0
  46. package/lib/inventory/agentFormulationParser.js +57 -4
  47. package/lib/inventory/aiCollector.js +504 -10
  48. package/lib/inventory/aiInventory.js +12 -0
  49. package/lib/inventory/analyzer.js +488 -30
  50. package/lib/inventory/astWorker.js +6 -0
  51. package/lib/inventory/auditCategories.js +8 -0
  52. package/lib/inventory/cbomutils.js +30 -0
  53. package/lib/inventory/cdxrs.js +7 -2
  54. package/lib/inventory/communityAiConfigParser.js +215 -2
  55. package/lib/inventory/dosai.js +77 -0
  56. package/lib/inventory/envcontext.js +9 -6
  57. package/lib/inventory/evidenceUtils.js +13 -0
  58. package/lib/inventory/fetchBatch.js +74 -2
  59. package/lib/inventory/fetchRate.js +74 -11
  60. package/lib/inventory/forgeEnricher.js +3 -2
  61. package/lib/inventory/jvmToolEnv.js +6 -5
  62. package/lib/inventory/mcpConfigParser.js +166 -2
  63. package/lib/inventory/mcpDiscovery.js +11 -0
  64. package/lib/inventory/mcpServerManifest.js +389 -0
  65. package/lib/inventory/mcpbBundle.js +419 -0
  66. package/lib/inventory/plugins.js +3 -0
  67. package/lib/inventory/protobom.js +54 -88
  68. package/lib/inventory/protobomLoader.js +4 -0
  69. package/lib/inventory/rustFormulationParser.js +1 -1
  70. package/lib/inventory/source.js +10 -4
  71. package/lib/inventory/stylePackageRefs.js +41 -0
  72. package/lib/inventory/svelteUtils.js +524 -0
  73. package/lib/parsers/huggingfaceUtils.js +15 -7
  74. package/lib/parsers/onnx.js +294 -0
  75. package/lib/parsers/safetensors.js +114 -0
  76. package/lib/stages/postgen/introspection/markers.js +29 -6
  77. package/lib/stages/postgen/postgen.js +169 -1
  78. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +462 -0
  79. package/package.json +25 -25
  80. package/types/lib/audit/targets.d.ts.map +1 -1
  81. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  82. package/types/lib/cli/index.d.ts +2 -2
  83. package/types/lib/cli/index.d.ts.map +1 -1
  84. package/types/lib/cli/jsBom.d.ts +23 -0
  85. package/types/lib/cli/jsBom.d.ts.map +1 -1
  86. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  87. package/types/lib/cli/managedBom.d.ts +33 -0
  88. package/types/lib/cli/managedBom.d.ts.map +1 -1
  89. package/types/lib/cli/nativeBom.d.ts +88 -8
  90. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  91. package/types/lib/core/activity.d.ts.map +1 -1
  92. package/types/lib/core/env.d.ts +21 -0
  93. package/types/lib/core/env.d.ts.map +1 -1
  94. package/types/lib/core/userAgent.d.ts +56 -0
  95. package/types/lib/core/userAgent.d.ts.map +1 -0
  96. package/types/lib/ecosystems/cargoCache.d.ts +75 -0
  97. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -0
  98. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  99. package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
  100. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  101. package/types/lib/ecosystems/ecosystems.d.ts +15 -1
  102. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  103. package/types/lib/ecosystems/gradleutils.d.ts +20 -0
  104. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  105. package/types/lib/ecosystems/kosi.d.ts +73 -0
  106. package/types/lib/ecosystems/kosi.d.ts.map +1 -0
  107. package/types/lib/ecosystems/parsers-crystal.d.ts +27 -0
  108. package/types/lib/ecosystems/parsers-crystal.d.ts.map +1 -0
  109. package/types/lib/ecosystems/parsers-dotnet.d.ts +19 -0
  110. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  111. package/types/lib/ecosystems/parsers-elm.d.ts +79 -0
  112. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -0
  113. package/types/lib/ecosystems/parsers-julia.d.ts +57 -0
  114. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -0
  115. package/types/lib/ecosystems/parsers-lua.d.ts +50 -0
  116. package/types/lib/ecosystems/parsers-lua.d.ts.map +1 -0
  117. package/types/lib/ecosystems/parsers-misc.d.ts +23 -0
  118. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  119. package/types/lib/ecosystems/parsers-nim.d.ts +43 -0
  120. package/types/lib/ecosystems/parsers-nim.d.ts.map +1 -0
  121. package/types/lib/ecosystems/parsers-opam.d.ts +31 -0
  122. package/types/lib/ecosystems/parsers-opam.d.ts.map +1 -0
  123. package/types/lib/ecosystems/parsers-perl.d.ts +36 -0
  124. package/types/lib/ecosystems/parsers-perl.d.ts.map +1 -0
  125. package/types/lib/ecosystems/parsers-r.d.ts +35 -0
  126. package/types/lib/ecosystems/parsers-r.d.ts.map +1 -0
  127. package/types/lib/ecosystems/parsers-rebar.d.ts +42 -0
  128. package/types/lib/ecosystems/parsers-rebar.d.ts.map +1 -0
  129. package/types/lib/ecosystems/parsers-rust.d.ts +199 -0
  130. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  131. package/types/lib/ecosystems/parsers-spack.d.ts +33 -0
  132. package/types/lib/ecosystems/parsers-spack.d.ts.map +1 -0
  133. package/types/lib/ecosystems/parsers-stack.d.ts +35 -0
  134. package/types/lib/ecosystems/parsers-stack.d.ts.map +1 -0
  135. package/types/lib/ecosystems/parsers-terraform.d.ts +34 -0
  136. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -0
  137. package/types/lib/ecosystems/pythonutils.d.ts +1 -1
  138. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  139. package/types/lib/evinser/evinser.d.ts +29 -0
  140. package/types/lib/evinser/evinser.d.ts.map +1 -1
  141. package/types/lib/helpers/agenticToolutils.d.ts +87 -0
  142. package/types/lib/helpers/agenticToolutils.d.ts.map +1 -0
  143. package/types/lib/inventory/agentFormulationParser.d.ts.map +1 -1
  144. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  145. package/types/lib/inventory/aiInventory.d.ts.map +1 -1
  146. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  147. package/types/lib/inventory/auditCategories.d.ts +4 -0
  148. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  149. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  150. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  151. package/types/lib/inventory/dosai.d.ts +23 -0
  152. package/types/lib/inventory/dosai.d.ts.map +1 -1
  153. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  154. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  155. package/types/lib/inventory/fetchBatch.d.ts +34 -0
  156. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  157. package/types/lib/inventory/fetchRate.d.ts +9 -6
  158. package/types/lib/inventory/fetchRate.d.ts.map +1 -1
  159. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  160. package/types/lib/inventory/jvmToolEnv.d.ts +5 -4
  161. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
  162. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  163. package/types/lib/inventory/mcpDiscovery.d.ts.map +1 -1
  164. package/types/lib/inventory/mcpServerManifest.d.ts +19 -0
  165. package/types/lib/inventory/mcpServerManifest.d.ts.map +1 -0
  166. package/types/lib/inventory/mcpbBundle.d.ts +19 -0
  167. package/types/lib/inventory/mcpbBundle.d.ts.map +1 -0
  168. package/types/lib/inventory/plugins.d.ts.map +1 -1
  169. package/types/lib/inventory/protobom.d.ts +18 -13
  170. package/types/lib/inventory/protobom.d.ts.map +1 -1
  171. package/types/lib/inventory/protobomLoader.d.ts.map +1 -1
  172. package/types/lib/inventory/source.d.ts.map +1 -1
  173. package/types/lib/inventory/stylePackageRefs.d.ts +17 -0
  174. package/types/lib/inventory/stylePackageRefs.d.ts.map +1 -0
  175. package/types/lib/inventory/svelteUtils.d.ts +124 -0
  176. package/types/lib/inventory/svelteUtils.d.ts.map +1 -0
  177. package/types/lib/parsers/huggingfaceUtils.d.ts +8 -1
  178. package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
  179. package/types/lib/parsers/onnx.d.ts +28 -0
  180. package/types/lib/parsers/onnx.d.ts.map +1 -0
  181. package/types/lib/parsers/safetensors.d.ts +22 -0
  182. package/types/lib/parsers/safetensors.d.ts.map +1 -0
  183. package/types/lib/stages/postgen/introspection/markers.d.ts +3 -2
  184. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -1
  185. package/types/lib/stages/postgen/postgen.d.ts +33 -0
  186. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
@@ -0,0 +1,1256 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import process from "node:process";
4
+
5
+ import { DEBUG_MODE, readEnvironmentVariable } from "../core/activity.js";
6
+ import {
7
+ getTmpDir,
8
+ safeExistsSync,
9
+ safeMkdtempSync,
10
+ safeRmSync,
11
+ safeSpawnSync,
12
+ } from "../core/fs.js";
13
+ import { resolvePluginBinary } from "../inventory/plugins.js";
14
+ import { tryBuildPurl, tryParsePurl } from "../inventory/purl.js";
15
+
16
+ /**
17
+ * kosi — JS bridge to the Kotlin-native evidence binary (thirdparty/kosi in
18
+ * cdxgen-plugins-bin). This module is the ONLY place that spawns kosi. It
19
+ * follows the cdxrs discipline: no failure mode may abort SBOM generation.
20
+ * A missing binary, a non-zero exit, a timeout or CDXGEN_KOSI_DISABLE logs
21
+ * once and returns undefined so callers keep the plain JS evidence.
22
+ *
23
+ * The consumed report is kosi's `analyze` JSON (schema kosi/1): usages[],
24
+ * callGraph, dataFlow.slices[], crypto and services[]; the attribute
25
+ * contract is thirdparty/kosi/JSON_ATTRIBUTE_REFERENCE.md.
26
+ */
27
+
28
+ const KOTLIN_LANGUAGES = new Set(["kotlin", "kt", "kotlin-lang"]);
29
+
30
+ /** Longest wait for a kosi analyze run. Large repos degrade, not hang. */
31
+ const DEFAULT_TIMEOUT_MS = 15 * 60_000;
32
+
33
+ /**
34
+ * Builds the workspace component candidate for one kosi module.
35
+ *
36
+ * kosi derives module names from Gradle settings files, so a quoted literal
37
+ * (rootProject.name = 'KotlinGoat') rides into the reported purl. A purl
38
+ * cdx-purl rejects anchors nothing and must not reach the BOM: keep the
39
+ * reported purl when it parses, rebuild one from the cleaned coordinates
40
+ * when it does not, and return undefined when neither works.
41
+ *
42
+ * @param {Object|undefined} workspaceModule One entry of kosi's modules[].
43
+ * @returns {Object|undefined} Component candidate with a valid purl, or undefined.
44
+ */
45
+ export function kosiWorkspaceComponent(workspaceModule) {
46
+ if (!workspaceModule?.purl) {
47
+ return undefined;
48
+ }
49
+ const version = workspaceModule.purl.split("@")[1] || "unspecified";
50
+ const cleanName = (workspaceModule.name || "").replace(/^['"]|['"]+$/g, "");
51
+ const purl =
52
+ tryParsePurl(workspaceModule.purl) ||
53
+ tryBuildPurl({ type: "generic", name: cleanName, version });
54
+ if (!purl) {
55
+ return undefined;
56
+ }
57
+ return {
58
+ type: "application",
59
+ name: cleanName || workspaceModule.name,
60
+ version,
61
+ purl,
62
+ "bom-ref": purl,
63
+ };
64
+ }
65
+
66
+ export function isKosiKotlinLanguage(language) {
67
+ return KOTLIN_LANGUAGES.has(String(language || "").toLowerCase());
68
+ }
69
+
70
+ /**
71
+ * True when kosi is disabled by CDXGEN_KOSI_DISABLE (1/all/true) or --no-kosi.
72
+ * The check happens before any binary resolution so a disabled run never
73
+ * even looks for the plugin.
74
+ */
75
+ export function kosiDisabled() {
76
+ const raw = (
77
+ readEnvironmentVariable("CDXGEN_KOSI_DISABLE") || ""
78
+ ).toLowerCase();
79
+ if (raw === "1" || raw === "true" || raw === "all") {
80
+ return true;
81
+ }
82
+ return process.argv.includes("--no-kosi");
83
+ }
84
+
85
+ /**
86
+ * Resolve the kosi command: KOSI_CMD wins (may be a native binary or the
87
+ * kosi-all.jar), then the bundled plugin binary.
88
+ */
89
+ function kosiCommand() {
90
+ const cmd = resolvePluginBinary("kosi");
91
+ if (!cmd) {
92
+ return undefined;
93
+ }
94
+ if (cmd.endsWith(".jar")) {
95
+ return { cmd: "java", args: ["-Djava.awt.headless=true", "-jar", cmd] };
96
+ }
97
+ return { cmd, args: [] };
98
+ }
99
+
100
+ /**
101
+ * Builds the kosi argv for one analyze mode.
102
+ */
103
+ function kosiArgs(kosi, src, outputFile, dataflowMode) {
104
+ const args = [
105
+ ...kosi.args,
106
+ "analyze",
107
+ "--backend",
108
+ "resolved",
109
+ "--dataflow",
110
+ dataflowMode,
111
+ "--deps",
112
+ "--endpoint-sources",
113
+ "--dir",
114
+ resolve(src),
115
+ "--out",
116
+ outputFile,
117
+ ];
118
+ const classpathFile = join(resolve(src), "classpath.txt");
119
+ if (safeExistsSync(classpathFile)) {
120
+ args.push("--classpath-file", classpathFile);
121
+ }
122
+ return args;
123
+ }
124
+
125
+ function runKosi(kosi, src, args, outputFile, options) {
126
+ const result = safeSpawnSync(kosi.cmd, args, {
127
+ cwd: resolve(src),
128
+ shell: false,
129
+ timeout: options.kosiTimeoutMs || DEFAULT_TIMEOUT_MS,
130
+ });
131
+ if (result?.status !== 0 || !safeExistsSync(outputFile)) {
132
+ if (DEBUG_MODE) {
133
+ console.error(result?.stdout, result?.stderr);
134
+ } else {
135
+ console.log(
136
+ "kosi: analyze did not produce a report; skipping the Kotlin native evidence.",
137
+ );
138
+ }
139
+ return undefined;
140
+ }
141
+ return JSON.parse(readFileSync(outputFile, "utf-8"));
142
+ }
143
+
144
+ /**
145
+ * Orchestrates the kosi runs over src and returns the parsed reports: one
146
+ * `all` pass (every slice kind, dependency tier) and one `reachable` pass
147
+ * (call-graph reachability from the roots). The analysis is read-only and
148
+ * offline: kosi never executes the project's build. A classpath.txt beside
149
+ * the sources (the documented build-tool report) is passed through when
150
+ * present.
151
+ *
152
+ * @param {string} src Directory to analyze.
153
+ * @param {Object} options Configuration options.
154
+ * @returns {Object|undefined} { report, reachableReport } or undefined.
155
+ */
156
+ export function analyzeKosiProject(src, options = {}) {
157
+ if (kosiDisabled()) {
158
+ console.log(
159
+ "kosi: disabled via CDXGEN_KOSI_DISABLE; skipping the Kotlin native evidence.",
160
+ );
161
+ return undefined;
162
+ }
163
+ const kosi = kosiCommand();
164
+ if (!kosi) {
165
+ console.log(
166
+ "kosi: plugin binary not found; skipping the Kotlin native evidence.",
167
+ );
168
+ return undefined;
169
+ }
170
+ const tempDir = safeMkdtempSync(join(getTmpDir(), "kosi-"));
171
+ try {
172
+ const outputFile = join(tempDir, "kosi-all.json");
173
+ const allArgs = kosiArgs(kosi, src, outputFile, "all");
174
+ if (DEBUG_MODE) {
175
+ console.log("Executing", kosi.cmd, allArgs.join(" "));
176
+ }
177
+ const report = runKosi(kosi, src, allArgs, outputFile, options);
178
+ if (!report) {
179
+ return undefined;
180
+ }
181
+ // The reachable pass answers a different question (call-graph
182
+ // reachability from the roots): its slice set is the intersection
183
+ // with that reachability, so its evidence is a strict subset of the
184
+ // all-pass's. Losing it would silently drop the filtered evidence,
185
+ // so a failure here names itself but keeps the all-pass evidence.
186
+ const reachableFile = join(tempDir, "kosi-reachable.json");
187
+ const reachableArgs = kosiArgs(kosi, src, reachableFile, "reachable");
188
+ const reachableReport = runKosi(
189
+ kosi,
190
+ src,
191
+ reachableArgs,
192
+ reachableFile,
193
+ options,
194
+ );
195
+ return { report, reachableReport };
196
+ } catch (error) {
197
+ if (DEBUG_MODE) {
198
+ console.error("kosi: analyze failed", error);
199
+ }
200
+ return undefined;
201
+ } finally {
202
+ safeRmSync(tempDir, { recursive: true, force: true });
203
+ }
204
+ }
205
+
206
+ function mergeSetMap(into, from) {
207
+ for (const [key, values] of Object.entries(from || {})) {
208
+ for (const value of values) {
209
+ addSetValue(into, key, value);
210
+ }
211
+ }
212
+ }
213
+
214
+ function mergeFramesMap(into, from) {
215
+ for (const [key, frames] of Object.entries(from || {})) {
216
+ into[key] ??= [];
217
+ into[key].push(...frames);
218
+ }
219
+ }
220
+
221
+ function mergePropertiesMap(into, from) {
222
+ for (const [key, properties] of Object.entries(from || {})) {
223
+ into[key] ??= [];
224
+ for (const property of properties) {
225
+ appendUniqueProperty(into[key], property.name, property.value);
226
+ }
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Merges the reachable pass's evidence into the all pass's maps, mutating
232
+ * the first argument.
233
+ *
234
+ * The reachable pass's slices are the all pass's INTERSECTED with call-graph
235
+ * reachability from the roots, so every key it produces is a key the all pass
236
+ * already has, and the unions below cannot add one. That made the second full
237
+ * `--backend resolved --deps` analysis — the most expensive thing this arm
238
+ * runs, and it runs it twice — contribute exactly nothing: the one fact it
239
+ * computes, reachability, was discarded at the merge (the P23 review's R144).
240
+ * It is recorded now, on the components whose evidence survived the
241
+ * intersection, which is the signal an SBOM consumer prioritises with. kosi's
242
+ * own schema deliberately stopped carrying `reachableFromRoots` per slice in
243
+ * P22 because there it was a mode-level fact wearing a per-slice field's
244
+ * clothes; here it is neither, it is the measured difference between two runs.
245
+ */
246
+ export function mergeKosiEvidence(all, reachable) {
247
+ if (!reachable) {
248
+ return all;
249
+ }
250
+ for (const purl of Object.keys(reachable.purlLocationMap || {})) {
251
+ addPropertyValue(
252
+ all.componentPropertiesMap,
253
+ purl,
254
+ "cdx:kosi:reachableFromRoots",
255
+ "true",
256
+ );
257
+ }
258
+ mergeSetMap(all.purlLocationMap, reachable.purlLocationMap);
259
+ mergeFramesMap(all.dataFlowFrames, reachable.dataFlowFrames);
260
+ mergePropertiesMap(
261
+ all.componentPropertiesMap,
262
+ reachable.componentPropertiesMap,
263
+ );
264
+ for (const component of reachable.cryptoComponents || []) {
265
+ if (
266
+ !all.cryptoComponents.some(
267
+ (existing) => existing["bom-ref"] === component["bom-ref"],
268
+ )
269
+ ) {
270
+ all.cryptoComponents.push(component);
271
+ }
272
+ }
273
+ return all;
274
+ }
275
+
276
+ function appendUniqueProperty(properties, name, value) {
277
+ if (value === undefined || value === null || value === "") {
278
+ return;
279
+ }
280
+ const propertyValue = String(value);
281
+ if (
282
+ !properties.some(
283
+ (property) => property.name === name && property.value === propertyValue,
284
+ )
285
+ ) {
286
+ properties.push({ name, value: propertyValue });
287
+ }
288
+ }
289
+
290
+ function addSetValue(map, key, value) {
291
+ if (!key || !value) {
292
+ return;
293
+ }
294
+ map[key] ??= new Set();
295
+ map[key].add(value);
296
+ }
297
+
298
+ function addPropertyValue(map, key, name, value) {
299
+ if (!key || value === undefined || value === null || value === "") {
300
+ return;
301
+ }
302
+ map[key] ??= [];
303
+ appendUniqueProperty(map[key], name, value);
304
+ }
305
+
306
+ function positionLocation(position) {
307
+ if (!position?.filename) {
308
+ return undefined;
309
+ }
310
+ if (position.line && position.line > 0) {
311
+ return `${position.filename}#${position.line}`;
312
+ }
313
+ return position.filename;
314
+ }
315
+
316
+ function purlWithoutVersion(purl) {
317
+ return purl?.split("?")[0].split("#")[0].split("@")[0];
318
+ }
319
+
320
+ function createPurlAliasMap(components = []) {
321
+ const purlAliasMap = new Map();
322
+ const nameIndex = new Map();
323
+ for (const component of components) {
324
+ if (!component?.purl) {
325
+ continue;
326
+ }
327
+ purlAliasMap.set(component.purl, component.purl);
328
+ const noVersionPurl = purlWithoutVersion(component.purl);
329
+ if (noVersionPurl && !purlAliasMap.has(noVersionPurl)) {
330
+ purlAliasMap.set(noVersionPurl, component.purl);
331
+ }
332
+ // kosi names jars from an offline classpath as pkg:generic/<file>@<v>
333
+ // (thirdparty/kosi/JSON_ATTRIBUTE_REFERENCE.md). Join those to the
334
+ // resolved component by NAME: "timber-5.0.1" -> the timber component.
335
+ if (component.name) {
336
+ nameIndex.set(String(component.name).toLowerCase(), component.purl);
337
+ const artifact = String(component.purl).match(
338
+ /pkg:[^/]+\/[^/]+\/([^/@?]+)@/,
339
+ );
340
+ if (artifact && !nameIndex.has(artifact[1].toLowerCase())) {
341
+ nameIndex.set(artifact[1].toLowerCase(), component.purl);
342
+ }
343
+ }
344
+ }
345
+ return { purlAliasMap, nameIndex };
346
+ }
347
+
348
+ function resolveComponentPurl(purl, purlAliasMap) {
349
+ if (!purl) {
350
+ return undefined;
351
+ }
352
+ return purlAliasMap.get(purl) || purlAliasMap.get(purlWithoutVersion(purl));
353
+ }
354
+
355
+ /**
356
+ * The offline-analysis join: a pkg:generic purl resolves through the name
357
+ * index (exact, then with a trailing version stripped). Unresolved purls
358
+ * stay unresolved — no purl is invented.
359
+ */
360
+ function resolveLoosePurl(purl, nameIndex) {
361
+ if (!purl?.startsWith("pkg:generic/")) {
362
+ return undefined;
363
+ }
364
+ let candidate = purl.slice("pkg:generic/".length).split("@")[0].split("?")[0];
365
+ candidate = decodeURIComponent(candidate).toLowerCase();
366
+ const hit = nameIndex.get(candidate);
367
+ if (hit) {
368
+ return hit;
369
+ }
370
+ const stripped = candidate.replace(/-v?\d+(?:\.\d+)*$/u, "");
371
+ return stripped === candidate ? undefined : nameIndex.get(stripped);
372
+ }
373
+
374
+ function resolveKosiPurl(purl, purlAliasMap, nameIndex) {
375
+ return (
376
+ resolveComponentPurl(purl, purlAliasMap) ||
377
+ resolveLoosePurl(purl, nameIndex)
378
+ );
379
+ }
380
+
381
+ function frameFromNode(node = {}) {
382
+ if (!node.filePath) {
383
+ return undefined;
384
+ }
385
+ return {
386
+ package: node.modulePath || node.purl || "",
387
+ module: node.kind || "",
388
+ function: node.name || "",
389
+ line: node.position?.line || undefined,
390
+ column: node.position?.column || undefined,
391
+ fullFilename: node.filePath,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * P24: kosi's per-slice `frames[]` are the named hops —
397
+ * (function, file, line, role), source first, sink last, with the
398
+ * callee-internal hops spliced in at every summary boundary. They are a
399
+ * BETTER callstack than the node-derived one (every hop names its
400
+ * function, not just its register), so they are preferred whenever the
401
+ * report carries them; the node walk stays as the pre-P24 fallback.
402
+ */
403
+ function framesFromSlice(slice) {
404
+ if (!Array.isArray(slice.frames) || !slice.frames.length) {
405
+ return undefined;
406
+ }
407
+ const frames = [];
408
+ for (const frame of slice.frames) {
409
+ if (!frame?.file) {
410
+ continue;
411
+ }
412
+ frames.push({
413
+ package: "",
414
+ module: frame.role || "",
415
+ function: frame.function || "",
416
+ line: frame.line || undefined,
417
+ column: undefined,
418
+ fullFilename: frame.file,
419
+ });
420
+ }
421
+ return frames.length ? frames : undefined;
422
+ }
423
+
424
+ function frameLocationKey(frame) {
425
+ if (!frame?.fullFilename) {
426
+ return undefined;
427
+ }
428
+ return `${frame.fullFilename}#${frame.line || ""}#${frame.column || ""}`;
429
+ }
430
+
431
+ function dedupeFrames(frames = []) {
432
+ const seen = new Set();
433
+ const out = [];
434
+ for (const frame of frames) {
435
+ const key = frameLocationKey(frame);
436
+ if (!key || seen.has(key)) {
437
+ continue;
438
+ }
439
+ seen.add(key);
440
+ out.push(frame);
441
+ }
442
+ return out;
443
+ }
444
+
445
+ function addFrame(dataFlowFrames, purl, frame) {
446
+ if (!purl || !frame) {
447
+ return;
448
+ }
449
+ dataFlowFrames[purl] ??= [];
450
+ dataFlowFrames[purl].push([frame]);
451
+ }
452
+
453
+ function cryptoBomRef(kind, name, detail) {
454
+ return `crypto/kosi/${kind}/${encodeURIComponent(name)}@${encodeURIComponent(detail || name)}`;
455
+ }
456
+
457
+ /**
458
+ * kosi's exit contract (kosi-cli Main.kt): `analyze` exits 0 whenever a
459
+ * report was written — a degraded run (budget trip, callgraph-failed, even
460
+ * ERROR-severity diagnostics) is DATA in the report, printed to stderr but
461
+ * never a crash. A non-zero exit means usage error or an uncaught failure,
462
+ * and in both cases no report file exists. So runKosi's discard on
463
+ * status !== 0 never throws away a partial report — there is never one to
464
+ * throw away — and a report that made it this far is always the complete
465
+ * record of what kosi did, degradations included. Those degradations are
466
+ * joined onto the BOM below; before that they stopped at the report and a
467
+ * half-read repository produced a BOM indistinguishable from a fully-read
468
+ * one (the same defect kosi itself was fixed for, one layer up).
469
+ */
470
+ function aggregateDiagnostics(kosiReport = {}) {
471
+ const byCode = new Map();
472
+ for (const diagnostic of kosiReport.diagnostics || []) {
473
+ if (!diagnostic?.code) {
474
+ continue;
475
+ }
476
+ // An entry without a count is one event (no-sources); a counted entry
477
+ // carries its own multiplicity (resolution-errors per file, count =
478
+ // errors in it), so the sum is the population the code affected.
479
+ const entry = byCode.get(diagnostic.code) ?? { count: 0, error: false };
480
+ entry.count += diagnostic.count ?? 1;
481
+ // Severity is the half a bare count cannot carry. `callgraph-failed`
482
+ // is an ERROR — the call graph is ABSENT from the report — while
483
+ // `resolution-errors` is a warning about imperfect typing on a run
484
+ // that otherwise completed. Published as one count each, they read
485
+ // alike, and a consumer sorting by count reads the warning as the
486
+ // bigger problem. Error wins for a code once any entry carries it.
487
+ entry.error = entry.error || diagnostic.severity === "error";
488
+ byCode.set(diagnostic.code, entry);
489
+ }
490
+ return byCode;
491
+ }
492
+
493
+ function addMetadataProperties(properties, kosiReport = {}) {
494
+ appendUniqueProperty(
495
+ properties,
496
+ "cdx:kosi:schemaVersion",
497
+ kosiReport.schemaVersion,
498
+ );
499
+ appendUniqueProperty(
500
+ properties,
501
+ "cdx:kosi:toolVersion",
502
+ kosiReport.tool?.version,
503
+ );
504
+ appendUniqueProperty(
505
+ properties,
506
+ "cdx:kosi:kotlinVersion",
507
+ kosiReport.runtime?.kotlinVersion,
508
+ );
509
+ // Signals are counted at the BOM level as well as attached to components,
510
+ // because one of them cannot be attached: a cinterop `.def` file carries no
511
+ // purl — it is not a module source — and attaching it to nothing would make
512
+ // a real finding disappear between the report and the BOM.
513
+ const signals = kosiReport.securitySignals || [];
514
+ if (signals.length) {
515
+ appendUniqueProperty(
516
+ properties,
517
+ "cdx:kosi:securitySignalCount",
518
+ String(signals.length),
519
+ );
520
+ for (const code of Array.from(
521
+ new Set(signals.map((signal) => signal?.code).filter(Boolean)),
522
+ ).sort()) {
523
+ appendUniqueProperty(properties, "cdx:kosi:securitySignal", code);
524
+ }
525
+ }
526
+ const stats = kosiReport.stats || {};
527
+ // A run kosi itself marked incomplete. `no-jdk` means the resolved tier
528
+ // attached no JDK, so every `java.*` symbol resolved to nothing;
529
+ // `kotlin-version` means a version mismatch coincided with heavy
530
+ // resolution fallout. Either way the counts below are not facts about the
531
+ // code, and a BOM that carries the counts without the caveat is the
532
+ // failure this property exists to prevent.
533
+ if (stats.degraded) {
534
+ appendUniqueProperty(properties, "cdx:kosi:degraded", stats.degraded);
535
+ }
536
+ if ((stats.sliceCount ?? 0) > 0) {
537
+ appendUniqueProperty(properties, "cdx:kosi:sliceCount", stats.sliceCount);
538
+ }
539
+ if ((stats.crossDependencySliceCount ?? 0) > 0) {
540
+ appendUniqueProperty(
541
+ properties,
542
+ "cdx:kosi:crossDependencySliceCount",
543
+ stats.crossDependencySliceCount,
544
+ );
545
+ }
546
+ const dataFlowStats = kosiReport.dataFlow?.stats || {};
547
+ if ((dataFlowStats.bytecodeSummaries ?? 0) > 0) {
548
+ appendUniqueProperty(
549
+ properties,
550
+ "cdx:kosi:bytecodeSummariesApplied",
551
+ dataFlowStats.bytecodeSummaries,
552
+ );
553
+ }
554
+ // Source coverage: discovery against what is on disk, published WITH its
555
+ // denominators (kosi's own rule — a rate without its denominator is not a
556
+ // result). The `source-coverage-gap` diagnostic fires on nonTestRatio;
557
+ // the numbers let a consumer judge runs the threshold stayed silent on.
558
+ const coverage = stats.sourceCoverage;
559
+ if (coverage) {
560
+ appendUniqueProperty(
561
+ properties,
562
+ "cdx:kosi:sourceCoverageDiscovered",
563
+ coverage.discovered,
564
+ );
565
+ appendUniqueProperty(
566
+ properties,
567
+ "cdx:kosi:sourceCoveragePresent",
568
+ coverage.present,
569
+ );
570
+ appendUniqueProperty(
571
+ properties,
572
+ "cdx:kosi:sourceCoverageTestPresent",
573
+ coverage.testPresent,
574
+ );
575
+ appendUniqueProperty(
576
+ properties,
577
+ "cdx:kosi:sourceCoverageRatio",
578
+ coverage.ratio,
579
+ );
580
+ appendUniqueProperty(
581
+ properties,
582
+ "cdx:kosi:sourceCoverageNonTestRatio",
583
+ coverage.nonTestRatio,
584
+ );
585
+ }
586
+ // Every degradation kosi suffered, named by its diagnostic code and
587
+ // counted. Silence here was the join losing the caveat: an endpoint kosi
588
+ // explicitly marked unread (endpoint-unsubstantiated), a budget trip
589
+ // (analysis-time-budget, rss-budget), a coverage gap — none of it reached
590
+ // the BOM, so the SaaSBOM's services and the metadata read exactly like a
591
+ // fully-analysed run's.
592
+ const diagnostics = aggregateDiagnostics(kosiReport);
593
+ for (const [code, entry] of diagnostics) {
594
+ appendUniqueProperty(
595
+ properties,
596
+ `cdx:kosi:diagnostic:${code}`,
597
+ entry.count,
598
+ );
599
+ }
600
+ // Which of those codes were ERRORS, in one property a consumer can read
601
+ // without knowing kosi's diagnostic vocabulary. An error means a piece of
602
+ // the analysis is ABSENT from this report (the call graph crashed, a file
603
+ // could not be read), not merely imperfect — and that is the difference a
604
+ // per-code count on its own cannot express.
605
+ const errorCodes = [...diagnostics]
606
+ .filter(([, entry]) => entry.error)
607
+ .map(([code]) => code)
608
+ .sort();
609
+ if (errorCodes.length) {
610
+ appendUniqueProperty(
611
+ properties,
612
+ "cdx:kosi:diagnosticErrors",
613
+ errorCodes.join(","),
614
+ );
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Occurrence evidence: every workspace/library usage with a resolved purl
620
+ * and a source position.
621
+ */
622
+ function addUsageEvidence(
623
+ kosiReport,
624
+ purlAliasMap,
625
+ nameIndex,
626
+ purlLocationMap,
627
+ dataFlowFrames,
628
+ ) {
629
+ for (const usage of kosiReport.usages || []) {
630
+ const purl = resolveKosiPurl(usage.purl, purlAliasMap, nameIndex);
631
+ if (!purl) {
632
+ continue;
633
+ }
634
+ addSetValue(purlLocationMap, purl, positionLocation(usage.position));
635
+ addFrame(dataFlowFrames, purl, {
636
+ package: usage.purl || "",
637
+ module: usage.kind || "",
638
+ function: usage.name || "",
639
+ line: usage.position?.line || undefined,
640
+ column: usage.position?.column || undefined,
641
+ fullFilename: usage.position?.filename,
642
+ });
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Callstack, reachability and data-flow evidence from the slices: each
648
+ * slice's trace walks source -> sink; the frames are the callstack, kosi's
649
+ * per-slice `pathKind` says what that trace IS (complete | partial |
650
+ * symbol-only), and the rule identities are the data-flow classification.
651
+ * Reachability is NOT here: it is the difference between the two passes and
652
+ * is stamped in `mergeKosiEvidence`. This sentence said "the
653
+ * reachableFromRoots flag is the reachability evidence" for a field kosi
654
+ * deleted in P22, seventy lines above the code that had already stopped
655
+ * reading it.
656
+ */
657
+ function addDataFlowEvidence(
658
+ kosiReport,
659
+ purlAliasMap,
660
+ nameIndex,
661
+ purlLocationMap,
662
+ dataFlowFrames,
663
+ componentPropertiesMap,
664
+ ) {
665
+ const dataFlow = kosiReport.dataFlow || {};
666
+ const nodeMap = new Map();
667
+ for (const node of dataFlow.nodes || []) {
668
+ nodeMap.set(node.id, node);
669
+ }
670
+ const dataFlowCounts = {};
671
+ for (const slice of dataFlow.slices || []) {
672
+ const purls = new Set();
673
+ for (const value of slice.purls || []) {
674
+ const resolvedPurl = resolveKosiPurl(value, purlAliasMap, nameIndex);
675
+ if (resolvedPurl) {
676
+ purls.add(resolvedPurl);
677
+ }
678
+ }
679
+ for (const nodeId of slice.nodeIds || []) {
680
+ const node = nodeMap.get(nodeId);
681
+ const resolvedPurl = resolveKosiPurl(node?.purl, purlAliasMap, nameIndex);
682
+ if (resolvedPurl) {
683
+ purls.add(resolvedPurl);
684
+ }
685
+ }
686
+ if (!purls.size) {
687
+ continue;
688
+ }
689
+ // P24: the named hops first; the node walk only when the report
690
+ // predates frames[].
691
+ const frames =
692
+ framesFromSlice(slice) ||
693
+ (slice.nodeIds || [])
694
+ .map((nodeId) => frameFromNode(nodeMap.get(nodeId)))
695
+ .filter(Boolean);
696
+ const deduped = dedupeFrames(frames);
697
+ const category = [slice.sourceCategory, slice.sinkCategory]
698
+ .filter(Boolean)
699
+ .join("->");
700
+ for (const purl of purls) {
701
+ dataFlowCounts[purl] = (dataFlowCounts[purl] || 0) + 1;
702
+ const sourceNode = nodeMap.get(slice.sourceId);
703
+ const sinkNode = nodeMap.get(slice.sinkId);
704
+ addSetValue(
705
+ purlLocationMap,
706
+ purl,
707
+ positionLocation(sourceNode?.position),
708
+ );
709
+ addSetValue(purlLocationMap, purl, positionLocation(sinkNode?.position));
710
+ if (deduped.length) {
711
+ dataFlowFrames[purl] ??= [];
712
+ dataFlowFrames[purl].push(deduped);
713
+ }
714
+ addPropertyValue(
715
+ componentPropertiesMap,
716
+ purl,
717
+ "cdx:kosi:dataFlowCategories",
718
+ category,
719
+ );
720
+ addPropertyValue(
721
+ componentPropertiesMap,
722
+ purl,
723
+ "cdx:kosi:dataFlowRuleName",
724
+ slice.ruleName,
725
+ );
726
+ // P22: kosi replaced the constant-false reachableFromRoots flag with
727
+ // pathKind (complete | partial | symbol-only) — what the slice's
728
+ // trace IS, published per slice instead of a mode-level fact.
729
+ if (slice.pathKind) {
730
+ addPropertyValue(
731
+ componentPropertiesMap,
732
+ purl,
733
+ "cdx:kosi:pathKind",
734
+ slice.pathKind,
735
+ );
736
+ }
737
+ if (slice.crossesDependency) {
738
+ addPropertyValue(
739
+ componentPropertiesMap,
740
+ purl,
741
+ "cdx:kosi:crossesDependency",
742
+ "true",
743
+ );
744
+ }
745
+ }
746
+ }
747
+ for (const [purl, count] of Object.entries(dataFlowCounts)) {
748
+ addPropertyValue(
749
+ componentPropertiesMap,
750
+ purl,
751
+ "cdx:kosi:dataFlowSliceCount",
752
+ count,
753
+ );
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Crypto-flow evidence: kosi's crypto collector yields the CBOM assets and
759
+ * materials; crypto-FLOW slices connect material stores to sinks.
760
+ */
761
+ /**
762
+ * kosi's `securitySignals[]` codes, with the severity each one carries. The
763
+ * vocabulary is CLOSED (kosi's 02-ARCHITECTURE.md §8), so this is a table and
764
+ * not a guess; a code kosi adds later arrives here as an unknown and is
765
+ * published with its code and no severity rather than silently dropped.
766
+ *
767
+ * kosi's signal shape is not golem's — `code`/`message`/`symbol` against
768
+ * golem's `category`/`severity` — which is why the golem reader could not
769
+ * simply be pointed at it, and why these signals reached no BOM at all while
770
+ * golem's did. kosi's report contract described the field as reserved and
771
+ * empty long after it began emitting, which is the likeliest reason nobody
772
+ * wired it up.
773
+ */
774
+ const KOSI_SIGNAL_SEVERITY = {
775
+ "native-interop": "info",
776
+ };
777
+
778
+ /**
779
+ * Non-flow findings: the JNI seams, `loadLibrary` binding sites and cinterop
780
+ * `.def` files kosi reports. Each signal already names the purl and module it
781
+ * attaches to, so no symbol-to-module resolution is needed — unlike golem,
782
+ * where the package path has to be matched back to a module.
783
+ */
784
+ function addSecuritySignalEvidence(
785
+ kosiReport,
786
+ purlAliasMap,
787
+ nameIndex,
788
+ purlLocationMap,
789
+ componentPropertiesMap,
790
+ ) {
791
+ for (const signal of kosiReport.securitySignals || []) {
792
+ const code = signal?.code;
793
+ if (!code) {
794
+ continue;
795
+ }
796
+ const purl = resolveKosiPurl(signal.purl, purlAliasMap, nameIndex);
797
+ if (!purl) {
798
+ continue;
799
+ }
800
+ addPropertyValue(
801
+ componentPropertiesMap,
802
+ purl,
803
+ "cdx:kosi:securitySignalCode",
804
+ code,
805
+ );
806
+ addPropertyValue(
807
+ componentPropertiesMap,
808
+ purl,
809
+ "cdx:kosi:securitySignalSeverity",
810
+ KOSI_SIGNAL_SEVERITY[code],
811
+ );
812
+ addPropertyValue(
813
+ componentPropertiesMap,
814
+ purl,
815
+ "cdx:kosi:securitySignalSymbol",
816
+ signal.symbol,
817
+ );
818
+ // A `.def` file carries a repo-relative path and no module of its own,
819
+ // so the filePath is the only location it has.
820
+ const location =
821
+ positionLocation(signal.position) ||
822
+ (signal.filePath ? signal.filePath : undefined);
823
+ if (location) {
824
+ addSetValue(purlLocationMap, purl, location);
825
+ }
826
+ }
827
+ }
828
+
829
+ function addCryptoEvidence(
830
+ kosiReport,
831
+ purlAliasMap,
832
+ nameIndex,
833
+ cryptoComponentsByRef,
834
+ componentPropertiesMap,
835
+ ) {
836
+ const crypto = kosiReport.crypto || {};
837
+ for (const asset of crypto.assets || []) {
838
+ const component = {
839
+ type: "cryptographic-asset",
840
+ name: asset.algorithm || asset.symbol || "unknown",
841
+ "bom-ref": cryptoBomRef(
842
+ "algorithm",
843
+ asset.algorithm || asset.symbol || "unknown",
844
+ asset.form || "literal",
845
+ ),
846
+ description: "Cryptographic algorithm detected by kosi source analysis",
847
+ cryptoProperties: {
848
+ assetType: "algorithm",
849
+ algorithmProperties: { primitive: asset.kind || "unknown" },
850
+ },
851
+ properties: [],
852
+ };
853
+ appendUniqueProperty(
854
+ component.properties,
855
+ "cdx:kosi:crypto:form",
856
+ asset.form,
857
+ );
858
+ appendUniqueProperty(
859
+ component.properties,
860
+ "cdx:kosi:crypto:resolution",
861
+ asset.resolution,
862
+ );
863
+ cryptoComponentsByRef.set(component["bom-ref"], component);
864
+ }
865
+ for (const material of crypto.materials || []) {
866
+ const component = {
867
+ type: "cryptographic-asset",
868
+ name: material.name || "material",
869
+ "bom-ref": cryptoBomRef(
870
+ "material",
871
+ material.name || "material",
872
+ material.kind || "secret",
873
+ ),
874
+ description:
875
+ "Cryptographic material indicator detected by kosi source analysis",
876
+ cryptoProperties: {
877
+ assetType: "related-crypto-material",
878
+ relatedCryptoMaterialProperties: { type: material.kind || "secret" },
879
+ },
880
+ properties: [],
881
+ };
882
+ appendUniqueProperty(
883
+ component.properties,
884
+ "cdx:kosi:crypto:function",
885
+ material.function,
886
+ );
887
+ cryptoComponentsByRef.set(component["bom-ref"], component);
888
+ }
889
+ for (const slice of kosiReport.dataFlow?.slices || []) {
890
+ const isCryptoFlow =
891
+ (slice.sourceCategory || "").startsWith("hardcoded") ||
892
+ slice.sinkCategory === "crypto-asset" ||
893
+ slice.sinkCategory === "insecure-tls";
894
+ if (!isCryptoFlow) {
895
+ continue;
896
+ }
897
+ for (const purl of slice.purls || []) {
898
+ // The SAME join every other evidence kind uses. This site alone used
899
+ // the exact-match resolver, so a crypto flow whose purl is an offline
900
+ // `pkg:generic/...` one — which is every workspace-local flow, and the
901
+ // hardcoded-secret -> crypto-asset flow on the sample is exactly that
902
+ // — silently attached to nothing while the other four kinds landed.
903
+ const resolvedPurl = resolveKosiPurl(purl, purlAliasMap, nameIndex);
904
+ if (resolvedPurl) {
905
+ addPropertyValue(
906
+ componentPropertiesMap,
907
+ resolvedPurl,
908
+ "cdx:kosi:cryptoFlow",
909
+ `${slice.sourceCategory}->${slice.sinkCategory}`,
910
+ );
911
+ }
912
+ }
913
+ }
914
+ }
915
+
916
+ /**
917
+ * Occurrence evidence for one service call site. Mirrors the dosai shape:
918
+ * `evidence.occurrences[]` is a CycloneDX 2.0 field that spec-version
919
+ * compatibility strips below 2.0, so the same location also lands in a
920
+ * property where it survives at 1.6 and 1.7.
921
+ */
922
+ function addServiceOccurrence(definition, filePath, position, enclosingSymbol) {
923
+ const path = filePath || position?.filename;
924
+ if (!path) {
925
+ return;
926
+ }
927
+ const location = {
928
+ path,
929
+ line: position?.line || undefined,
930
+ column: position?.column || undefined,
931
+ };
932
+ definition.evidence ??= { occurrences: [] };
933
+ definition.evidence.occurrences ??= [];
934
+ const key = `${location.path}#${location.line}#${location.column}`;
935
+ const known = definition.evidence.occurrences;
936
+ if (
937
+ !known.some(
938
+ (seen) =>
939
+ `${seen?.location?.path}#${seen?.location?.line}#${seen?.location?.column}` ===
940
+ key,
941
+ )
942
+ ) {
943
+ known.push({ location });
944
+ }
945
+ definition.properties ??= [];
946
+ appendUniqueProperty(
947
+ definition.properties,
948
+ "cdx:kosi:service:location",
949
+ `${location.path}:${location.line || 0}:${location.column || 0}`,
950
+ );
951
+ appendUniqueProperty(
952
+ definition.properties,
953
+ "cdx:kosi:service:enclosingSymbol",
954
+ enclosingSymbol,
955
+ );
956
+ }
957
+
958
+ /**
959
+ * kosi's `urls[]` rows, indexed by the value they carry. Every outbound call
960
+ * site appears here with its file, position, enclosing symbol and resolution,
961
+ * while `services[]` carries ONE position for the service as a whole — so a
962
+ * service reached from three places gets three occurrences only through this
963
+ * index. Both spellings are indexed (`url` is the normalised value, `raw` the
964
+ * source text) because a service's `endpoints[]` may match either.
965
+ */
966
+ function urlEvidenceIndex(kosiReport = {}) {
967
+ const index = new Map();
968
+ for (const row of kosiReport.urls || []) {
969
+ for (const value of [row?.url, row?.raw]) {
970
+ if (!value) {
971
+ continue;
972
+ }
973
+ if (!index.has(value)) {
974
+ index.set(value, []);
975
+ }
976
+ const rows = index.get(value);
977
+ if (!rows.includes(row)) {
978
+ rows.push(row);
979
+ }
980
+ }
981
+ }
982
+ return index;
983
+ }
984
+
985
+ /**
986
+ * Services evidence: kosi's outbound services[] rows are already
987
+ * CycloneDX-shaped.
988
+ */
989
+ export function collectKosiServices(kosiReport = {}, servicesMap = {}) {
990
+ const urlIndex = urlEvidenceIndex(kosiReport);
991
+ for (const service of kosiReport.services || []) {
992
+ if (!service?.id || !service?.name) {
993
+ continue;
994
+ }
995
+ const definition = (servicesMap[service.id] ??= {
996
+ name: service.name,
997
+ bomRef: service.id,
998
+ endpoints: new Set(),
999
+ properties: [],
1000
+ });
1001
+ for (const endpoint of service.endpoints || []) {
1002
+ definition.endpoints.add(endpoint);
1003
+ }
1004
+ if (typeof definition.authenticated === "undefined") {
1005
+ definition.authenticated = service.authenticated ?? undefined;
1006
+ }
1007
+ // kosi publishes the HYPHENATED CycloneDX key, not the camelCase one this
1008
+ // read used, so every kosi service lost its trust boundary — the property
1009
+ // was never present under the name being asked for. It also belongs in
1010
+ // `x-trust-boundary`, which the normaliser maps to the CycloneDX field;
1011
+ // `trustZone` is a different concept and put the flag somewhere no
1012
+ // consumer reads it as one.
1013
+ const trustBoundary = service["x-trust-boundary"] ?? service.xTrustBoundary;
1014
+ if (
1015
+ typeof trustBoundary === "boolean" &&
1016
+ typeof definition["x-trust-boundary"] === "undefined"
1017
+ ) {
1018
+ definition["x-trust-boundary"] = trustBoundary;
1019
+ }
1020
+ // Where the call was made. The service's own position is one call site;
1021
+ // the url rows carry the rest, with the function each one sits in.
1022
+ addServiceOccurrence(
1023
+ definition,
1024
+ service.position?.filename,
1025
+ service.position,
1026
+ );
1027
+ for (const endpoint of service.endpoints || []) {
1028
+ for (const row of urlIndex.get(endpoint) || []) {
1029
+ addServiceOccurrence(
1030
+ definition,
1031
+ row.filePath,
1032
+ row.position,
1033
+ row.enclosingSymbol,
1034
+ );
1035
+ appendUniqueProperty(
1036
+ definition.properties,
1037
+ "cdx:kosi:service:urlResolution",
1038
+ row.resolution,
1039
+ );
1040
+ }
1041
+ }
1042
+ appendUniqueProperty(
1043
+ definition.properties,
1044
+ "cdx:kosi:service:protocol",
1045
+ service.protocol,
1046
+ );
1047
+ appendUniqueProperty(
1048
+ definition.properties,
1049
+ "cdx:kosi:service:resolution",
1050
+ service.resolution,
1051
+ );
1052
+ appendUniqueProperty(
1053
+ definition.properties,
1054
+ "cdx:kosi:service:clientLibrary",
1055
+ service.clientLibrary,
1056
+ );
1057
+ }
1058
+ return servicesMap;
1059
+ }
1060
+
1061
+ /**
1062
+ * INBOUND endpoints: kosi's apiEndpoints[] rows are the routes the analysed
1063
+ * application itself serves, which `services[]` (kosi's OUTBOUND calls)
1064
+ * does not carry. Without this the whole inbound surface — the path
1065
+ * template, the framework that declared it, and above all whether anything
1066
+ * authenticates it — stopped at the kosi report and never reached the
1067
+ * SaaSBOM.
1068
+ *
1069
+ * One service per endpoint, named the way detectServicesFromOpenAPI names
1070
+ * its own (`service-<path>-<method>`), so an OpenAPI spec and a kosi run
1071
+ * over the same route converge on one entry instead of two.
1072
+ *
1073
+ * `authenticated` is left UNDEFINED when the endpoint declares no
1074
+ * authentication: kosi's empty list means "nothing was declared here",
1075
+ * which is not the same claim as "this route is unauthenticated", and
1076
+ * cdxgen's own detectors use undefined for exactly that unknown.
1077
+ *
1078
+ * @param {Object} kosiReport The parsed JSON generated by kosi.
1079
+ * @param {Object} servicesMap Map populated with detected service definitions
1080
+ * @returns {Object} The mutated services map
1081
+ */
1082
+ export function collectKosiApiEndpoints(kosiReport = {}, servicesMap = {}) {
1083
+ for (const endpoint of kosiReport.apiEndpoints || []) {
1084
+ const pathTemplate = endpoint?.pathTemplate;
1085
+ if (!pathTemplate) {
1086
+ continue;
1087
+ }
1088
+ // kosi's schema field is httpMethod (SINGULAR) — reading a plural
1089
+ // `httpMethods` silently fell to ["ALL"] for every endpoint, losing the
1090
+ // verb and splitting one route into kosi's service-x-all beside an
1091
+ // OpenAPI spec's service-x-get: the duplicate the shared naming exists
1092
+ // to prevent. Found by running the join on a real kosi report (P16 §4).
1093
+ const methods = endpoint.httpMethod?.length ? endpoint.httpMethod : ["ALL"];
1094
+ for (const httpMethod of methods) {
1095
+ const serviceName = `service-${pathTemplate.replaceAll("/", "")}-${httpMethod.toLowerCase()}`;
1096
+ const definition = (servicesMap[serviceName] ??= {
1097
+ name: serviceName,
1098
+ endpoints: new Set(),
1099
+ authenticated: undefined,
1100
+ xTrustBoundary: undefined,
1101
+ properties: [],
1102
+ });
1103
+ definition.properties ??= [];
1104
+ definition.endpoints ??= new Set();
1105
+ definition.endpoints.add(pathTemplate);
1106
+ if (endpoint.authentication?.length) {
1107
+ definition.authenticated = true;
1108
+ definition.xTrustBoundary = true;
1109
+ for (const scheme of endpoint.authentication) {
1110
+ appendUniqueProperty(
1111
+ definition.properties,
1112
+ "cdx:kosi:endpoint:authentication",
1113
+ scheme,
1114
+ );
1115
+ }
1116
+ }
1117
+ appendUniqueProperty(
1118
+ definition.properties,
1119
+ "cdx:service:httpMethod",
1120
+ httpMethod,
1121
+ );
1122
+ appendUniqueProperty(
1123
+ definition.properties,
1124
+ "cdx:kosi:endpoint:framework",
1125
+ endpoint.framework,
1126
+ );
1127
+ appendUniqueProperty(
1128
+ definition.properties,
1129
+ "cdx:kosi:endpoint:handler",
1130
+ endpoint.handlerCanonicalName || endpoint.handlerSymbol,
1131
+ );
1132
+ // The caveat kosi spent rounds learning to say: `substantiated:
1133
+ // false` means the manifest DECLARES this endpoint and kosi read none
1134
+ // of the code behind it (a library component, or a run that discovered
1135
+ // no sources). The endpoint is still published — the manifest is real
1136
+ // — but as `false` on the service, so the SaaSBOM no longer renders an
1137
+ // unread attack surface identical to a fully-analysed one. Only false
1138
+ // is stamped: absence keeps meaning "read", which is every other
1139
+ // endpoint, and a positive property on all of them is noise.
1140
+ if (endpoint.substantiated === false) {
1141
+ appendUniqueProperty(
1142
+ definition.properties,
1143
+ "cdx:kosi:endpoint:substantiated",
1144
+ "false",
1145
+ );
1146
+ }
1147
+ for (const mediaType of endpoint.consumes || []) {
1148
+ appendUniqueProperty(
1149
+ definition.properties,
1150
+ "cdx:kosi:endpoint:consumes",
1151
+ mediaType,
1152
+ );
1153
+ }
1154
+ for (const mediaType of endpoint.produces || []) {
1155
+ appendUniqueProperty(
1156
+ definition.properties,
1157
+ "cdx:kosi:endpoint:produces",
1158
+ mediaType,
1159
+ );
1160
+ }
1161
+ }
1162
+ }
1163
+ return servicesMap;
1164
+ }
1165
+
1166
+ /**
1167
+ * Extracts and maps CycloneDX evidence structures from a kosi report.
1168
+ *
1169
+ * @param {Object} kosiReport The parsed JSON generated by kosi.
1170
+ * @param {Array} components The components present in the SBOM.
1171
+ * @returns {Object} Maps representing evidence structures.
1172
+ */
1173
+ function addImportEvidence(
1174
+ kosiReport,
1175
+ purlAliasMap,
1176
+ nameIndex,
1177
+ purlLocationMap,
1178
+ dataFlowFrames,
1179
+ ) {
1180
+ for (const imported of kosiReport.imports || []) {
1181
+ const purl = resolveKosiPurl(imported.purl, purlAliasMap, nameIndex);
1182
+ if (!purl) {
1183
+ continue;
1184
+ }
1185
+ addSetValue(purlLocationMap, purl, positionLocation(imported.position));
1186
+ addFrame(dataFlowFrames, purl, {
1187
+ package: imported.purl || "",
1188
+ module: "import",
1189
+ function: imported.name || "",
1190
+ line: imported.position?.line || undefined,
1191
+ column: imported.position?.column || undefined,
1192
+ fullFilename: imported.position?.filename,
1193
+ });
1194
+ }
1195
+ }
1196
+
1197
+ export function collectKosiEvidence(kosiReport = {}, components = []) {
1198
+ const { purlAliasMap, nameIndex } = createPurlAliasMap(components);
1199
+ const purlLocationMap = {};
1200
+ const dataFlowFrames = {};
1201
+ const componentPropertiesMap = {};
1202
+ const metadataProperties = [];
1203
+ const cryptoComponentsByRef = new Map();
1204
+
1205
+ addMetadataProperties(metadataProperties, kosiReport);
1206
+ addImportEvidence(
1207
+ kosiReport,
1208
+ purlAliasMap,
1209
+ nameIndex,
1210
+ purlLocationMap,
1211
+ dataFlowFrames,
1212
+ );
1213
+ addUsageEvidence(
1214
+ kosiReport,
1215
+ purlAliasMap,
1216
+ nameIndex,
1217
+ purlLocationMap,
1218
+ dataFlowFrames,
1219
+ );
1220
+ addDataFlowEvidence(
1221
+ kosiReport,
1222
+ purlAliasMap,
1223
+ nameIndex,
1224
+ purlLocationMap,
1225
+ dataFlowFrames,
1226
+ componentPropertiesMap,
1227
+ );
1228
+ addCryptoEvidence(
1229
+ kosiReport,
1230
+ purlAliasMap,
1231
+ nameIndex,
1232
+ cryptoComponentsByRef,
1233
+ componentPropertiesMap,
1234
+ );
1235
+ addSecuritySignalEvidence(
1236
+ kosiReport,
1237
+ purlAliasMap,
1238
+ nameIndex,
1239
+ purlLocationMap,
1240
+ componentPropertiesMap,
1241
+ );
1242
+
1243
+ return {
1244
+ componentPropertiesMap,
1245
+ cryptoComponents: Array.from(cryptoComponentsByRef.values()).sort(
1246
+ (left, right) =>
1247
+ `${left.name}:${left["bom-ref"]}`.localeCompare(
1248
+ `${right.name}:${right["bom-ref"]}`,
1249
+ ),
1250
+ ),
1251
+ cryptoGeneratePurls: {},
1252
+ dataFlowFrames,
1253
+ metadataProperties,
1254
+ purlLocationMap,
1255
+ };
1256
+ }