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