@cdxgen/cdxgen 13.2.0 → 13.4.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 (311) hide show
  1. package/README.md +33 -14
  2. package/bin/cdxgen.js +310 -138
  3. package/bin/evinse.js +22 -0
  4. package/bin/repl.js +86 -0
  5. package/bin/sign.js +119 -15
  6. package/bin/tracebom.js +69 -5
  7. package/bin/validate.js +107 -19
  8. package/bin/verify.js +172 -63
  9. package/data/helpers/init.gradle +46 -21
  10. package/data/remediations.json +85 -0
  11. package/data/rules/dependency-sources.yaml +67 -0
  12. package/data/rules/scala-evidence.yaml +115 -0
  13. package/lib/audit/index.js +38 -6
  14. package/lib/cli/bomAssembly.js +105 -15
  15. package/lib/cli/cliOptions.js +93 -1
  16. package/lib/cli/index.js +156 -58
  17. package/lib/cli/jsBom.js +99 -53
  18. package/lib/cli/jvmBom.js +708 -147
  19. package/lib/cli/managedBom.js +415 -224
  20. package/lib/cli/nativeBom.js +264 -44
  21. package/lib/core/activity.js +12 -0
  22. package/lib/core/env.js +22 -15
  23. package/lib/core/fs.js +176 -7
  24. package/lib/core/httpClient.js +159 -24
  25. package/lib/core/metadataProperties.js +97 -0
  26. package/lib/core/pluginRun.js +184 -0
  27. package/lib/core/processTree.js +100 -0
  28. package/lib/core/projectConfig.js +31 -4
  29. package/lib/core/runState.js +39 -0
  30. package/lib/core/toml.js +72 -0
  31. package/lib/ecosystems/asarutils.js +4 -2
  32. package/lib/ecosystems/bunutils.js +274 -108
  33. package/lib/ecosystems/cargoCache.js +231 -8
  34. package/lib/ecosystems/caxa.js +434 -2
  35. package/lib/ecosystems/cmakeResolver.js +76 -21
  36. package/lib/ecosystems/cppBuildContext.js +631 -0
  37. package/lib/ecosystems/cppEvidence.js +399 -51
  38. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  39. package/lib/ecosystems/denoutils.js +93 -66
  40. package/lib/ecosystems/ecosystems.js +1983 -793
  41. package/lib/ecosystems/golem.js +28 -12
  42. package/lib/ecosystems/gradleutils.js +356 -37
  43. package/lib/ecosystems/jsEvidence.js +104 -44
  44. package/lib/ecosystems/kosi.js +31 -7
  45. package/lib/ecosystems/npmutils.js +174 -9
  46. package/lib/ecosystems/parsers-dotnet.js +616 -159
  47. package/lib/ecosystems/parsers-elm.js +46 -28
  48. package/lib/ecosystems/parsers-gleam.js +3 -4
  49. package/lib/ecosystems/parsers-go.js +371 -70
  50. package/lib/ecosystems/parsers-js.js +263 -76
  51. package/lib/ecosystems/parsers-julia.js +2 -3
  52. package/lib/ecosystems/parsers-jvm.js +204 -12
  53. package/lib/ecosystems/parsers-misc.js +929 -216
  54. package/lib/ecosystems/parsers-python.js +167 -24
  55. package/lib/ecosystems/parsers-rust.js +28 -22
  56. package/lib/ecosystems/parsers-terraform.js +1573 -19
  57. package/lib/ecosystems/pythonutils.js +102 -6
  58. package/lib/ecosystems/remote/huggingface.js +3 -0
  59. package/lib/ecosystems/rubyutils.js +1492 -182
  60. package/lib/ecosystems/rusi.js +54 -10
  61. package/lib/ecosystems/sbtutils.js +243 -187
  62. package/lib/ecosystems/scalacliutils.js +342 -0
  63. package/lib/ecosystems/scalasem.js +2618 -0
  64. package/lib/ecosystems/terraformRegistry.js +288 -0
  65. package/lib/ecosystems/utils.js +2 -0
  66. package/lib/ecosystems/vendoredCode.js +156 -0
  67. package/lib/evinser/evinser.js +1008 -191
  68. package/lib/evinser/swiftsem.js +1914 -302
  69. package/lib/helpers/agenticToolutils.js +2 -2
  70. package/lib/helpers/bomSigner.js +1101 -222
  71. package/lib/inventory/aiCollector.js +4 -1
  72. package/lib/inventory/analyzer.js +110 -127
  73. package/lib/inventory/atomUtils.js +703 -43
  74. package/lib/inventory/auditCategories.js +7 -0
  75. package/lib/inventory/buildToolRateLimit.js +144 -0
  76. package/lib/inventory/cacheCommands.js +2 -1
  77. package/lib/inventory/cdxrs.js +103 -12
  78. package/lib/inventory/cmakeBuildDirs.js +254 -0
  79. package/lib/inventory/deps.js +501 -124
  80. package/lib/inventory/depsUtils.js +183 -11
  81. package/lib/inventory/display.js +4 -5
  82. package/lib/inventory/dosai.js +798 -57
  83. package/lib/inventory/dosaiParsers.js +310 -15
  84. package/lib/inventory/dynamic.js +90 -6
  85. package/lib/inventory/evidenceUtils.js +166 -113
  86. package/lib/inventory/fetchBatch.js +690 -55
  87. package/lib/inventory/fetchDiskCache.js +597 -0
  88. package/lib/inventory/forgeEnricher.js +14 -5
  89. package/lib/inventory/jvmLocalRepos.js +917 -0
  90. package/lib/inventory/mcpConfigParser.js +2 -3
  91. package/lib/inventory/parentSelection.js +146 -0
  92. package/lib/inventory/protobom.js +8 -1
  93. package/lib/inventory/purl.js +152 -3
  94. package/lib/inventory/rustFormulationParser.js +3 -4
  95. package/lib/inventory/sbomDocument.js +2 -70
  96. package/lib/inventory/scalaCoords.js +153 -0
  97. package/lib/inventory/source.js +45 -27
  98. package/lib/inventory/spdx.js +101 -0
  99. package/lib/inventory/traceRunner.js +295 -25
  100. package/lib/inventory/tracedCrypto.js +294 -0
  101. package/lib/managers/binary.js +115 -24
  102. package/lib/managers/docker.js +467 -46
  103. package/lib/managers/oci.js +63 -3
  104. package/lib/parsers/cmakeCache.js +69 -0
  105. package/lib/parsers/cmakeLists.js +466 -0
  106. package/lib/parsers/cmakePresets.js +400 -0
  107. package/lib/parsers/compileCommands.js +557 -0
  108. package/lib/parsers/largeJson.js +454 -0
  109. package/lib/parsers/npmrc.js +19 -1
  110. package/lib/parsers/strictJson.js +322 -0
  111. package/lib/parsers/terraformConfig.js +1073 -0
  112. package/lib/parsers/terraformSource.js +652 -0
  113. package/lib/server/server.js +23 -0
  114. package/lib/stages/postgen/introspection/markers.js +1 -1
  115. package/lib/stages/postgen/postgen.js +83 -16
  116. package/lib/stages/postgen/specVersionCompat.js +32 -0
  117. package/lib/validator/bomValidator.js +6 -16
  118. package/lib/validator/index.js +64 -11
  119. package/lib/validator/reporters/console.js +3 -0
  120. package/lib/validator/reporters/json.js +2 -0
  121. package/lib/validator/reporters/sarif.js +7 -0
  122. package/package.json +23 -23
  123. package/types/lib/audit/index.d.ts.map +1 -1
  124. package/types/lib/cli/bomAssembly.d.ts +2 -1
  125. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  126. package/types/lib/cli/cliOptions.d.ts +33 -1
  127. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  128. package/types/lib/cli/index.d.ts.map +1 -1
  129. package/types/lib/cli/jsBom.d.ts.map +1 -1
  130. package/types/lib/cli/jvmBom.d.ts +24 -1
  131. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  132. package/types/lib/cli/managedBom.d.ts +7 -4
  133. package/types/lib/cli/managedBom.d.ts.map +1 -1
  134. package/types/lib/cli/nativeBom.d.ts +3 -1
  135. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  136. package/types/lib/core/activity.d.ts.map +1 -1
  137. package/types/lib/core/env.d.ts +9 -0
  138. package/types/lib/core/env.d.ts.map +1 -1
  139. package/types/lib/core/fs.d.ts +55 -0
  140. package/types/lib/core/fs.d.ts.map +1 -1
  141. package/types/lib/core/httpClient.d.ts +13 -8
  142. package/types/lib/core/httpClient.d.ts.map +1 -1
  143. package/types/lib/core/metadataProperties.d.ts +39 -0
  144. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  145. package/types/lib/core/pluginRun.d.ts +97 -0
  146. package/types/lib/core/pluginRun.d.ts.map +1 -0
  147. package/types/lib/core/processTree.d.ts +36 -0
  148. package/types/lib/core/processTree.d.ts.map +1 -0
  149. package/types/lib/core/projectConfig.d.ts +6 -2
  150. package/types/lib/core/projectConfig.d.ts.map +1 -1
  151. package/types/lib/core/runState.d.ts +29 -0
  152. package/types/lib/core/runState.d.ts.map +1 -0
  153. package/types/lib/core/toml.d.ts +20 -0
  154. package/types/lib/core/toml.d.ts.map +1 -0
  155. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  156. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  157. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  158. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  159. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  160. package/types/lib/ecosystems/caxa.d.ts +14 -0
  161. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  162. package/types/lib/ecosystems/cmakeResolver.d.ts +28 -3
  163. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  164. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  165. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  166. package/types/lib/ecosystems/cppEvidence.d.ts +17 -1
  167. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  168. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  169. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  170. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  171. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  172. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  173. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  174. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  175. package/types/lib/ecosystems/gradleutils.d.ts +70 -4
  176. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  177. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  178. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  179. package/types/lib/ecosystems/kosi.d.ts +13 -0
  180. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  181. package/types/lib/ecosystems/npmutils.d.ts +57 -0
  182. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  183. package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
  184. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  185. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  186. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  187. package/types/lib/ecosystems/parsers-go.d.ts +54 -2
  188. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  189. package/types/lib/ecosystems/parsers-js.d.ts +16 -5
  190. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  191. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  192. package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
  193. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  194. package/types/lib/ecosystems/parsers-misc.d.ts +70 -4
  195. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  196. package/types/lib/ecosystems/parsers-python.d.ts +53 -3
  197. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  198. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  199. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  200. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  201. package/types/lib/ecosystems/pythonutils.d.ts +27 -2
  202. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  203. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  204. package/types/lib/ecosystems/rubyutils.d.ts +164 -3
  205. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  206. package/types/lib/ecosystems/rusi.d.ts +13 -0
  207. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  208. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  209. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  210. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  211. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  212. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  213. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  214. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  215. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  216. package/types/lib/ecosystems/utils.d.ts +2 -2
  217. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  218. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  219. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  220. package/types/lib/evinser/evinser.d.ts +132 -15
  221. package/types/lib/evinser/evinser.d.ts.map +1 -1
  222. package/types/lib/evinser/swiftsem.d.ts +280 -15
  223. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  224. package/types/lib/helpers/bomSigner.d.ts +182 -13
  225. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  226. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  227. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  228. package/types/lib/inventory/atomUtils.d.ts +219 -5
  229. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  230. package/types/lib/inventory/auditCategories.d.ts +3 -0
  231. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  232. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  233. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  234. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  235. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  236. package/types/lib/inventory/cdxrs.d.ts +8 -3
  237. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  238. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  239. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  240. package/types/lib/inventory/deps.d.ts +59 -1
  241. package/types/lib/inventory/deps.d.ts.map +1 -1
  242. package/types/lib/inventory/depsUtils.d.ts +15 -0
  243. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  244. package/types/lib/inventory/display.d.ts.map +1 -1
  245. package/types/lib/inventory/dosai.d.ts +121 -9
  246. package/types/lib/inventory/dosai.d.ts.map +1 -1
  247. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  248. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  249. package/types/lib/inventory/dynamic.d.ts +10 -0
  250. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  251. package/types/lib/inventory/evidenceUtils.d.ts +19 -4
  252. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  253. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  254. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  255. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  256. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  257. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  258. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  259. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  260. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  261. package/types/lib/inventory/parentSelection.d.ts +34 -0
  262. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  263. package/types/lib/inventory/plugins.d.ts.map +1 -1
  264. package/types/lib/inventory/protobom.d.ts.map +1 -1
  265. package/types/lib/inventory/purl.d.ts +71 -2
  266. package/types/lib/inventory/purl.d.ts.map +1 -1
  267. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  268. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  269. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  270. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  271. package/types/lib/inventory/source.d.ts.map +1 -1
  272. package/types/lib/inventory/spdx.d.ts +9 -0
  273. package/types/lib/inventory/spdx.d.ts.map +1 -1
  274. package/types/lib/inventory/traceRunner.d.ts +59 -1
  275. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  276. package/types/lib/inventory/tracedCrypto.d.ts +42 -0
  277. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  278. package/types/lib/managers/binary.d.ts +30 -1
  279. package/types/lib/managers/binary.d.ts.map +1 -1
  280. package/types/lib/managers/docker.d.ts +29 -0
  281. package/types/lib/managers/docker.d.ts.map +1 -1
  282. package/types/lib/managers/oci.d.ts +17 -1
  283. package/types/lib/managers/oci.d.ts.map +1 -1
  284. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  285. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  286. package/types/lib/parsers/cmakeLists.d.ts +77 -0
  287. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  288. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  289. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  290. package/types/lib/parsers/compileCommands.d.ts +115 -0
  291. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  292. package/types/lib/parsers/largeJson.d.ts +52 -0
  293. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  294. package/types/lib/parsers/npmrc.d.ts +5 -0
  295. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  296. package/types/lib/parsers/strictJson.d.ts +46 -0
  297. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  298. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  299. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  300. package/types/lib/parsers/terraformSource.d.ts +66 -0
  301. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  302. package/types/lib/server/server.d.ts.map +1 -1
  303. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  304. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  305. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  306. package/types/lib/validator/index.d.ts +18 -1
  307. package/types/lib/validator/index.d.ts.map +1 -1
  308. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  309. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  310. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
  311. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
@@ -1,5 +1,14 @@
1
- import { readFileSync } from "node:fs";
2
- import { basename, delimiter, join, resolve } from "node:path";
1
+ import { Buffer } from "node:buffer";
2
+ import {
3
+ closeSync,
4
+ openSync,
5
+ readFileSync,
6
+ readSync,
7
+ realpathSync,
8
+ statSync,
9
+ } from "node:fs";
10
+ import { basename, delimiter, isAbsolute, join, resolve } from "node:path";
11
+ import process from "node:process";
3
12
 
4
13
  import { DEBUG_MODE } from "../core/activity.js";
5
14
  import { recordDegradation } from "../core/buildLedger.js";
@@ -9,8 +18,16 @@ import {
9
18
  safeMkdtempSync,
10
19
  safeRmSync,
11
20
  safeSpawnSync,
21
+ safeWriteChunksSync,
12
22
  safeWriteSync,
23
+ TIMEOUT_MS,
13
24
  } from "../core/fs.js";
25
+ import { formatDuration, reapProcessTree } from "../core/processTree.js";
26
+ import {
27
+ indexJsonObject,
28
+ MAX_JSON_TEXT_BYTES,
29
+ readJsonRange,
30
+ } from "../parsers/largeJson.js";
14
31
  import {
15
32
  addDosaiSetValue,
16
33
  buildDosaiPurlAliasMap,
@@ -181,11 +198,318 @@ function safeDosaiPatternPacks(value) {
181
198
  .join(delimiter);
182
199
  }
183
200
 
201
+ const MAX_DOSAI_EXCLUDES = 256;
202
+ const MAX_BRACE_RANGE = 64;
203
+ const GLOB_ESCAPABLE = "*?[]{}()!+@,";
204
+ // Characters a translated dosai glob may contain. dosai runs without a shell,
205
+ // but keeping shell metacharacters out means a pattern can never change
206
+ // meaning even if that ever changes. Anything else is reported as skipped.
207
+ const SAFE_DOSAI_GLOB = /^[\p{L}\p{N} _.+@#%~=/*?-]+$/u;
208
+ // Characters a dosai executable path may contain, including Windows drive
209
+ // letters and "Program Files (x86)". Shell metacharacters are not allowed.
210
+ const SAFE_DOSAI_EXECUTABLE = /^[\p{L}\p{N} _.,+@#%~=/\\:()-]+$/u;
211
+ const warnedDosaiMessages = new Set();
212
+ const dosaiWithoutExclude = new Set();
213
+
214
+ function warnDosaiOnce(message) {
215
+ if (!warnedDosaiMessages.has(message)) {
216
+ warnedDosaiMessages.add(message);
217
+ console.warn(message);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Find the brace group starting at `open` and return its closing index and the
223
+ * top-level comma positions, or undefined when the brace is never closed.
224
+ */
225
+ function scanBraceGroup(pattern, open) {
226
+ let depth = 0;
227
+ const commas = [];
228
+ for (let i = open; i < pattern.length; i++) {
229
+ const char = pattern[i];
230
+ if (char === "{") {
231
+ depth++;
232
+ } else if (char === "}") {
233
+ depth--;
234
+ if (depth === 0) {
235
+ return { close: i, commas };
236
+ }
237
+ } else if (char === "," && depth === 1) {
238
+ commas.push(i);
239
+ }
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ function braceAlternatives(pattern, open, group) {
245
+ const body = pattern.slice(open + 1, group.close);
246
+ if (group.commas.length) {
247
+ const bounds = [open, ...group.commas, group.close];
248
+ const alternatives = [];
249
+ for (let i = 0; i < bounds.length - 1; i++) {
250
+ alternatives.push(pattern.slice(bounds[i] + 1, bounds[i + 1]));
251
+ }
252
+ return alternatives;
253
+ }
254
+ // minimatch also expands small integer ranges such as {1..3}.
255
+ const rangeParts = body.split("..");
256
+ if (
257
+ rangeParts.length === 2 &&
258
+ /^-?\d+$/.test(rangeParts[0]) &&
259
+ /^-?\d+$/.test(rangeParts[1])
260
+ ) {
261
+ const from = Number.parseInt(rangeParts[0], 10);
262
+ const to = Number.parseInt(rangeParts[1], 10);
263
+ if (Math.abs(to - from) < MAX_BRACE_RANGE) {
264
+ const step = from <= to ? 1 : -1;
265
+ const alternatives = [];
266
+ for (let value = from; value !== to + step; value += step) {
267
+ alternatives.push(String(value));
268
+ }
269
+ return alternatives;
270
+ }
271
+ }
272
+ return undefined;
273
+ }
274
+
275
+ /**
276
+ * Expand brace groups the way minimatch does. A group without a comma or a
277
+ * numeric range, and a group written as "${...}", stays literal text.
278
+ */
279
+ function expandGlobBraces(pattern, limit = MAX_DOSAI_EXCLUDES) {
280
+ let open = pattern.indexOf("{");
281
+ while (open !== -1) {
282
+ const group = scanBraceGroup(pattern, open);
283
+ if (!group) {
284
+ return [pattern];
285
+ }
286
+ const alternatives =
287
+ pattern[open - 1] === "$"
288
+ ? undefined
289
+ : braceAlternatives(pattern, open, group);
290
+ if (alternatives) {
291
+ const head = pattern.slice(0, open);
292
+ const tail = pattern.slice(group.close + 1);
293
+ const expanded = [];
294
+ for (const alternative of alternatives) {
295
+ for (const entry of expandGlobBraces(
296
+ `${head}${alternative}${tail}`,
297
+ limit,
298
+ )) {
299
+ expanded.push(entry);
300
+ if (expanded.length >= limit) {
301
+ return expanded;
302
+ }
303
+ }
304
+ }
305
+ return expanded;
306
+ }
307
+ open = pattern.indexOf("{", group.close + 1);
308
+ }
309
+ return [pattern];
310
+ }
311
+
312
+ /**
313
+ * Normalise separators the way the atom exclude filter does: a backslash
314
+ * before a glob character is an escape, any other backslash is a Windows path
315
+ * separator. Returns undefined for escapes, which dosai cannot express.
316
+ */
317
+ function normalizeDosaiGlobSeparators(pattern) {
318
+ let normalized = "";
319
+ for (let i = 0; i < pattern.length; i++) {
320
+ const char = pattern[i];
321
+ if (char !== "\\") {
322
+ normalized += char;
323
+ continue;
324
+ }
325
+ if (GLOB_ESCAPABLE.includes(pattern[i + 1] || "")) {
326
+ return undefined;
327
+ }
328
+ normalized += "/";
329
+ }
330
+ return normalized;
331
+ }
332
+
333
+ function hasUnsupportedGlobSyntax(glob) {
334
+ if (glob.includes("[") || glob.includes("]")) {
335
+ return true;
336
+ }
337
+ for (let i = 1; i < glob.length; i++) {
338
+ if (glob[i] === "(" && "@?+*!".includes(glob[i - 1])) {
339
+ return true;
340
+ }
341
+ }
342
+ return false;
343
+ }
344
+
345
+ /**
346
+ * Rebase an absolute glob onto one of the scanned roots. Matching ignores case
347
+ * on macOS and Windows, as cdxgen's own glob does there.
348
+ */
349
+ function rebaseAbsoluteGlob(glob, roots) {
350
+ const ignoreCase =
351
+ process.platform === "darwin" || process.platform === "win32";
352
+ const candidate = ignoreCase ? glob.toLowerCase() : glob;
353
+ for (const root of roots) {
354
+ let rootPath = root.replaceAll("\\", "/");
355
+ while (rootPath.endsWith("/")) {
356
+ rootPath = rootPath.slice(0, -1);
357
+ }
358
+ const rootPrefix = `${rootPath}/`;
359
+ const prefix = ignoreCase ? rootPrefix.toLowerCase() : rootPrefix;
360
+ if (candidate.startsWith(prefix)) {
361
+ const rest = glob.slice(prefix.length);
362
+ return rest ? rest : undefined;
363
+ }
364
+ }
365
+ return undefined;
366
+ }
367
+
368
+ /**
369
+ * Translate cdxgen --exclude globs into dosai --exclude globs.
370
+ *
371
+ * Dosai is an evidence analyzer, so the translation follows the exclude
372
+ * filter cdxgen already applies to atom evidence slices
373
+ * (`globPatternsToAtomIgnoreRegex`): a relative pattern matches at any depth,
374
+ * and an absolute pattern is anchored to the scanned directory. Dosai itself
375
+ * uses gitignore conventions, where a pattern with a slash is anchored, so
376
+ * relative patterns get a leading "**\/". Like the atom filter, and unlike
377
+ * cdxgen's file discovery, excluding a directory also excludes everything
378
+ * beneath it.
379
+ *
380
+ * Brace groups and numeric ranges are expanded, and comma separated lists are
381
+ * split, since dosai reads both as literal text. Character classes, extglobs,
382
+ * escapes, negation, and patterns that step outside the scanned directory
383
+ * have no dosai equivalent; they are reported as skipped rather than passed
384
+ * on as literals that match nothing. So is any pattern that still carries a
385
+ * character outside a small allowlist, such as a literal brace group or a
386
+ * shell metacharacter. Dosai matches case-sensitively on Linux.
387
+ *
388
+ * @param {string[]} excludes cdxgen exclude globs
389
+ * @param {string} srcPath Absolute directory dosai scans
390
+ * @returns {{patterns: string[], skipped: string[], truncated: number}} Translated patterns, dropped patterns, and how many were left out by the cap
391
+ */
392
+ export function toDosaiExcludePatterns(excludes, srcPath) {
393
+ const patterns = new Set();
394
+ const skipped = [];
395
+ let truncated = 0;
396
+ if (!Array.isArray(excludes)) {
397
+ return { patterns: [], skipped, truncated };
398
+ }
399
+ const roots = [srcPath];
400
+ try {
401
+ const realRoot = realpathSync(srcPath);
402
+ if (realRoot !== srcPath) {
403
+ roots.push(realRoot);
404
+ }
405
+ } catch (_err) {
406
+ // The directory may not exist yet in tests; the given path still applies.
407
+ }
408
+ const candidates = [];
409
+ for (const pattern of excludes) {
410
+ if (typeof pattern !== "string" || /[\0\r\n]/.test(pattern)) {
411
+ continue;
412
+ }
413
+ const entries =
414
+ pattern.includes(",") && !pattern.includes("{")
415
+ ? pattern.split(",")
416
+ : [pattern];
417
+ for (const entry of entries) {
418
+ const trimmed = entry.trim();
419
+ if (trimmed) {
420
+ candidates.push(trimmed);
421
+ }
422
+ }
423
+ }
424
+ for (const original of candidates) {
425
+ const normalized = original.startsWith("!")
426
+ ? undefined
427
+ : normalizeDosaiGlobSeparators(original);
428
+ if (!normalized) {
429
+ skipped.push(original);
430
+ continue;
431
+ }
432
+ for (const expanded of expandGlobBraces(normalized)) {
433
+ let glob = expanded;
434
+ while (glob.includes("//")) {
435
+ glob = glob.replaceAll("//", "/");
436
+ }
437
+ if (hasUnsupportedGlobSyntax(glob)) {
438
+ skipped.push(expanded);
439
+ continue;
440
+ }
441
+ if (isAbsolute(glob) || /^[A-Za-z]:\//.test(glob)) {
442
+ const rebased = rebaseAbsoluteGlob(glob, roots);
443
+ if (!rebased) {
444
+ skipped.push(expanded);
445
+ continue;
446
+ }
447
+ glob = `/${rebased}`;
448
+ } else {
449
+ while (glob.startsWith("./")) {
450
+ glob = glob.slice(2);
451
+ }
452
+ if (glob && !glob.startsWith("**/") && glob !== "**") {
453
+ glob = `**/${glob}`;
454
+ }
455
+ }
456
+ const segments = glob.split("/").filter(Boolean);
457
+ if (
458
+ !segments.length ||
459
+ segments.includes("..") ||
460
+ segments.every((segment) => segment === ".")
461
+ ) {
462
+ skipped.push(expanded);
463
+ continue;
464
+ }
465
+ if (!SAFE_DOSAI_GLOB.test(glob)) {
466
+ skipped.push(expanded);
467
+ continue;
468
+ }
469
+ if (patterns.size >= MAX_DOSAI_EXCLUDES) {
470
+ if (!patterns.has(glob)) {
471
+ truncated++;
472
+ }
473
+ continue;
474
+ }
475
+ patterns.add(glob);
476
+ }
477
+ }
478
+ return { patterns: Array.from(patterns), skipped, truncated };
479
+ }
480
+
481
+ function dosaiExcludeArgs(excludes, srcPath) {
482
+ const { patterns, skipped, truncated } = toDosaiExcludePatterns(
483
+ excludes,
484
+ srcPath,
485
+ );
486
+ if (skipped.length) {
487
+ warnDosaiOnce(
488
+ `dosai cannot express these exclude patterns and will not apply them: ${skipped.join(", ")}`,
489
+ );
490
+ }
491
+ if (truncated) {
492
+ warnDosaiOnce(
493
+ `Only the first ${MAX_DOSAI_EXCLUDES} exclude patterns are passed to dosai; ${truncated} more were left out.`,
494
+ );
495
+ }
496
+ return patterns.flatMap((pattern) => ["--exclude", pattern]);
497
+ }
498
+
499
+ function dosaiRejectedExclude(result) {
500
+ const output = `${result?.stdout || ""}${result?.stderr || ""}`;
501
+ return output.includes("Unrecognized command or argument '--exclude'");
502
+ }
503
+
184
504
  function safeDosaiExecutable(value) {
185
- if (!value || typeof value !== "string" || /[\0\r\n]/.test(value)) {
505
+ if (!value || typeof value !== "string") {
506
+ return undefined;
507
+ }
508
+ const executable = value.trim();
509
+ if (!SAFE_DOSAI_EXECUTABLE.test(executable)) {
186
510
  return undefined;
187
511
  }
188
- return value.trim();
512
+ return executable;
189
513
  }
190
514
 
191
515
  /**
@@ -215,6 +539,207 @@ export function readDosaiJsonFile(jsonFile) {
215
539
  }
216
540
  }
217
541
 
542
+ // The sections of a dosai methods report that cdxgen reads. The rest (Methods,
543
+ // Properties, Fields, Reachability, DeadCode, ...) only reaches downstream
544
+ // tools through the persisted native report.
545
+ const DOSAI_METHODS_SECTIONS = [
546
+ "Metadata",
547
+ "Dependencies",
548
+ "MethodCalls",
549
+ "AssemblyInformation",
550
+ "CallGraph",
551
+ "PackageReachability",
552
+ "Services",
553
+ "ApiEndpoints",
554
+ "AiComponents",
555
+ ];
556
+
557
+ // The file each report returned by readDosaiReport came from, and whether it
558
+ // was trimmed to what cdxgen reads. persistDosaiSemanticsReport copies the
559
+ // file rather than re-serialising a trimmed or oversized report.
560
+ const dosaiReportSources = new WeakMap();
561
+
562
+ function referencedIds(entries, field) {
563
+ const ids = new Set();
564
+ for (const entry of entries || []) {
565
+ for (const id of entry?.[field] || []) {
566
+ ids.add(id);
567
+ }
568
+ }
569
+ return ids;
570
+ }
571
+
572
+ function readDosaiReport(reportFile, options, readTrimmed) {
573
+ if (!reportFile || !safeExistsSync(reportFile)) {
574
+ return undefined;
575
+ }
576
+ try {
577
+ const size = statSync(reportFile).size;
578
+ let report;
579
+ let trimmed = false;
580
+ if (size <= (options.maxTextBytes ?? MAX_JSON_TEXT_BYTES)) {
581
+ report = JSON.parse(readFileSync(reportFile, "utf-8"));
582
+ } else {
583
+ // dotnet/efcore produces a 1.9 GB methods report (issue 1033). Read it
584
+ // in bounded runs and keep only what cdxgen uses, which is a small
585
+ // fraction of the file.
586
+ if (DEBUG_MODE) {
587
+ console.log(
588
+ `The dosai report "${reportFile}" is ${size} bytes, more than one JavaScript string can hold, so only the parts cdxgen uses are read from it.`,
589
+ );
590
+ }
591
+ const index = indexJsonObject(reportFile, options);
592
+ report = {};
593
+ readTrimmed(report, (section, spec) => {
594
+ const range = index.get(section);
595
+ if (range) {
596
+ report[section] = readJsonRange(reportFile, range, spec, options);
597
+ }
598
+ });
599
+ trimmed = true;
600
+ }
601
+ if (report && typeof report === "object") {
602
+ dosaiReportSources.set(report, { file: reportFile, trimmed });
603
+ }
604
+ return report;
605
+ } catch (err) {
606
+ if (DEBUG_MODE) {
607
+ console.log(
608
+ `Unable to read the dosai report "${reportFile}": ${err.message}`,
609
+ );
610
+ }
611
+ return undefined;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Read a dosai methods report of any size.
617
+ *
618
+ * A report that fits in one JavaScript string is parsed whole. A larger one is
619
+ * read in bounded runs, keeping the requested sections, the CallGraph nodes and
620
+ * edges that PackageReachability references, and the MethodCalls accepted by
621
+ * `keepMethodCall` (none when it is omitted). Nothing cdxgen reads is lost.
622
+ *
623
+ * @param {string} reportFile Path to the dosai methods JSON
624
+ * @param {Object} [options] Options
625
+ * @param {string[]} [options.sections] Sections to keep from a large report (default: every section cdxgen reads)
626
+ * @param {Function} [options.keepMethodCall] Predicate choosing the MethodCalls to keep from a large report
627
+ * @param {number} [options.maxTextBytes] Largest report parsed whole, for tests
628
+ * @returns {Object|undefined} Parsed report, or undefined when missing or invalid
629
+ */
630
+ export function readDosaiMethodsReport(reportFile, options = {}) {
631
+ const sections = options.sections || DOSAI_METHODS_SECTIONS;
632
+ return readDosaiReport(reportFile, options, (report, read) => {
633
+ for (const section of sections) {
634
+ if (section !== "CallGraph" && section !== "MethodCalls") {
635
+ read(section);
636
+ }
637
+ }
638
+ if (sections.includes("MethodCalls") && options.keepMethodCall) {
639
+ read("MethodCalls", { filter: options.keepMethodCall });
640
+ }
641
+ if (sections.includes("CallGraph")) {
642
+ // CallGraph precedes PackageReachability in the file, which is why the
643
+ // sections are read by range rather than in one pass.
644
+ const edgeIds = referencedIds(report.PackageReachability, "EdgeIds");
645
+ const nodeIds = referencedIds(report.PackageReachability, "NodeIds");
646
+ read("CallGraph", {
647
+ members: {
648
+ Edges: { filter: (edge) => edgeIds.has(edge?.Id) },
649
+ Nodes: { filter: (node) => nodeIds.has(node?.Id) },
650
+ },
651
+ });
652
+ }
653
+ });
654
+ }
655
+
656
+ /**
657
+ * Read a dosai data-flow report of any size.
658
+ *
659
+ * A report that fits in one JavaScript string is parsed whole. A larger one
660
+ * keeps Metadata, Slices, PackageReachability, and the Nodes those reference.
661
+ *
662
+ * @param {string} reportFile Path to the dosai data-flow JSON
663
+ * @param {Object} [options] Options
664
+ * @param {number} [options.maxTextBytes] Largest report parsed whole, for tests
665
+ * @returns {Object|undefined} Parsed report, or undefined when missing or invalid
666
+ */
667
+ export function readDosaiDataFlowReport(reportFile, options = {}) {
668
+ return readDosaiReport(reportFile, options, (report, read) => {
669
+ read("Metadata");
670
+ read("Slices");
671
+ read("PackageReachability");
672
+ const nodeIds = referencedIds(report.Slices, "NodeIds");
673
+ for (const id of referencedIds(report.PackageReachability, "NodeIds")) {
674
+ nodeIds.add(id);
675
+ }
676
+ read("Nodes", { filter: (node) => nodeIds.has(node?.Id) });
677
+ });
678
+ }
679
+
680
+ /**
681
+ * Whether a dosai run ended because it ran out of time.
682
+ *
683
+ * dosai has no limit of its own: cdxgen's spawn timeout
684
+ * (CDXGEN_TIMEOUT_MS) stops the process, which reaches the caller only as an
685
+ * ETIMEDOUT error looking like any other failure (issue 4438).
686
+ *
687
+ * @param {Object} result spawnSync result
688
+ * @returns {boolean} true when the run was stopped by the spawn timeout
689
+ */
690
+ export function dosaiRunTimedOut(result) {
691
+ return result?.error?.code === "ETIMEDOUT";
692
+ }
693
+
694
+ /**
695
+ * How a dosai run was stopped before it could finish, if it was.
696
+ *
697
+ * Besides the spawn timeout, a run is stopped when its console output outgrows
698
+ * CDXGEN_MAX_BUFFER (ENOBUFS) or a signal ends it, such as the kernel's
699
+ * out-of-memory killer. A stopped run can leave its slice half written.
700
+ *
701
+ * @param {Object} result spawnSync result
702
+ * @returns {"timeout"|"buffer"|"signal"|undefined} Why the run was stopped, or undefined when it ended by itself
703
+ */
704
+ export function dosaiRunStopReason(result) {
705
+ if (dosaiRunTimedOut(result)) {
706
+ return "timeout";
707
+ }
708
+ if (result?.error?.code === "ENOBUFS") {
709
+ return "buffer";
710
+ }
711
+ if (result?.signal) {
712
+ return "signal";
713
+ }
714
+ return undefined;
715
+ }
716
+
717
+ // The output files of the dosai runs of this process that were stopped, so a
718
+ // caller does not follow the stop warning with advice for another failure.
719
+ const stoppedDosaiOutputs = new Set();
720
+
721
+ /**
722
+ * Whether the dosai run that should have written a file was stopped (by the
723
+ * time limit, the output buffer limit, or a signal) and already reported so.
724
+ *
725
+ * @param {string} outputFile Output file passed to the dosai run
726
+ * @returns {boolean} true when the run was stopped
727
+ */
728
+ export function dosaiRunWasStopped(outputFile) {
729
+ const outputPath = safeDosaiPath(outputFile);
730
+ return Boolean(outputPath && stoppedDosaiOutputs.has(outputPath));
731
+ }
732
+
733
+ function dosaiStopMessage(reason, signal) {
734
+ if (reason === "timeout") {
735
+ return `WARN: dosai did not finish within ${formatDuration(TIMEOUT_MS)} and was stopped, so its slices are missing or incomplete. Allow more time with CDXGEN_TIMEOUT_MS (milliseconds, every external command).`;
736
+ }
737
+ if (reason === "buffer") {
738
+ return "WARN: dosai wrote more console output than CDXGEN_MAX_BUFFER allows and was stopped, so its slices are missing or incomplete. Raise CDXGEN_MAX_BUFFER (bytes).";
739
+ }
740
+ return `WARN: dosai was stopped by ${signal || "a signal"} before it finished, so its slices are missing or incomplete. A SIGKILL usually means the system ran out of memory.`;
741
+ }
742
+
218
743
  /**
219
744
  * Run a dosai subcommand ("methods", "dataflows", or "crypto") against a source
220
745
  * tree and write its JSON output to the given file.
@@ -222,7 +747,7 @@ export function readDosaiJsonFile(jsonFile) {
222
747
  * @param {string} command Dosai subcommand to execute
223
748
  * @param {string} src Source directory to analyze
224
749
  * @param {string} outputFile Path where the dosai JSON output is written
225
- * @param {Object} [options] Options carrying dosaiCommand, dataFlowPatterns, or patternPacks overrides
750
+ * @param {Object} [options] Options carrying dosaiCommand, dataFlowPatterns, patternPacks, or exclude globs
226
751
  * @returns {boolean} True when the command succeeded and produced the output file, false otherwise
227
752
  */
228
753
  export function runDosaiCommand(command, src, outputFile, options = {}) {
@@ -254,13 +779,35 @@ export function runDosaiCommand(command, src, outputFile, options = {}) {
254
779
  } else if (command === "crypto") {
255
780
  args.push("--format", "dosai");
256
781
  }
782
+ const excludeArgs = dosaiWithoutExclude.has(executable)
783
+ ? []
784
+ : dosaiExcludeArgs(options.exclude, srcPath);
257
785
  if (DEBUG_MODE) {
258
- console.log("Executing", executable, args.join(" "));
786
+ console.log("Executing", executable, [...args, ...excludeArgs].join(" "));
259
787
  }
260
- const result = safeSpawnSync(executable, args, {
788
+ stoppedDosaiOutputs.delete(outputPath);
789
+ let startedAt = Date.now();
790
+ let result = safeSpawnSync(executable, [...args, ...excludeArgs], {
261
791
  cwd: srcPath,
262
792
  shell: false,
263
793
  });
794
+ // Dosai releases that predate --exclude reject it. Rerun without it so a
795
+ // user supplied exclude never turns into a missing slice.
796
+ if (
797
+ excludeArgs.length &&
798
+ result?.status !== 0 &&
799
+ dosaiRejectedExclude(result)
800
+ ) {
801
+ dosaiWithoutExclude.add(executable);
802
+ warnDosaiOnce(
803
+ "This dosai build does not support --exclude, so it runs without the exclude patterns. Upgrade dosai or set DOSAI_CMD to a newer build to honour them.",
804
+ );
805
+ startedAt = Date.now();
806
+ result = safeSpawnSync(executable, args, {
807
+ cwd: srcPath,
808
+ shell: false,
809
+ });
810
+ }
264
811
  if (dosaiSdkMessage(result)) {
265
812
  recordDegradation("dotnet.sdk.missing", {
266
813
  ecosystem: "csharp",
@@ -277,6 +824,22 @@ export function runDosaiCommand(command, src, outputFile, options = {}) {
277
824
  "Alternatively, download the dosai self-contained binary (-full suffix) from https://github.com/owasp-dep-scan/dosai/releases and set DOSAI_CMD to its location.",
278
825
  );
279
826
  }
827
+ const stopReason = dosaiRunStopReason(result);
828
+ if (stopReason) {
829
+ // Visible without debug output: otherwise a stopped dosai reads like any
830
+ // other failure while the scan silently loses its .NET evidence.
831
+ console.warn(dosaiStopMessage(stopReason, result.signal));
832
+ // Windows kills only the dosai process itself, and the build hosts it
833
+ // started would keep running.
834
+ reapProcessTree(result.pid, startedAt, Date.now(), "dosai");
835
+ // A stopped dosai can leave its output half written, and the caller would
836
+ // read the truncated file back as a slice.
837
+ if (safeExistsSync(outputPath)) {
838
+ safeRmSync(outputPath, { force: true });
839
+ }
840
+ stoppedDosaiOutputs.add(outputPath);
841
+ return false;
842
+ }
280
843
  if (result?.status !== 0 || result?.error || !safeExistsSync(outputPath)) {
281
844
  if (DEBUG_MODE) {
282
845
  if (result?.stderr || result?.stdout) {
@@ -369,6 +932,38 @@ function buildCombinedDosaiReport(methodsSlice, dataFlowSlice) {
369
932
  };
370
933
  }
371
934
 
935
+ function* fileChunks(filePath) {
936
+ const fd = openSync(filePath, "r");
937
+ try {
938
+ const buffer = Buffer.allocUnsafe(8 * 1024 * 1024);
939
+ for (;;) {
940
+ const count = readSync(fd, buffer, 0, buffer.length, null);
941
+ if (!count) {
942
+ return;
943
+ }
944
+ yield buffer.subarray(0, count);
945
+ }
946
+ } finally {
947
+ closeSync(fd);
948
+ }
949
+ }
950
+
951
+ // The combined report as JSON text in pieces, with each part copied from its
952
+ // native file when there is one. Keys and their order match
953
+ // buildCombinedDosaiReport.
954
+ function* combinedDosaiReportChunks(combined, parts) {
955
+ yield `{"Metadata":${JSON.stringify(combined.Metadata)}`;
956
+ for (const [key, source] of parts) {
957
+ yield `,${JSON.stringify(key)}:`;
958
+ if (source) {
959
+ yield* fileChunks(source.file);
960
+ } else {
961
+ yield JSON.stringify(combined[key]);
962
+ }
963
+ }
964
+ yield "}";
965
+ }
966
+
372
967
  /**
373
968
  * Persist the combined native dosai report to options.semanticsSlicesFile.
374
969
  *
@@ -399,16 +994,42 @@ export function persistDosaiSemanticsReport(
399
994
  return undefined;
400
995
  }
401
996
  const combined = buildCombinedDosaiReport(methodsSlice, dataFlowSlice);
997
+ const methodsSource = dosaiReportSources.get(methodsSlice);
998
+ const dataFlowSource = dosaiReportSources.get(dataFlowSlice);
402
999
  try {
403
- safeWriteSync(durablePath, JSON.stringify(combined));
1000
+ let text;
1001
+ if (!methodsSource?.trimmed && !dataFlowSource?.trimmed) {
1002
+ try {
1003
+ text = JSON.stringify(combined);
1004
+ } catch (err) {
1005
+ // Too large for one string: write it in parts below instead.
1006
+ if (!(err instanceof RangeError)) {
1007
+ throw err;
1008
+ }
1009
+ }
1010
+ }
1011
+ if (text !== undefined) {
1012
+ safeWriteSync(durablePath, text);
1013
+ } else {
1014
+ // A trimmed report lacks the sections cdxgen skipped, so each part is
1015
+ // copied from its native file when it has one, and serialised on its
1016
+ // own otherwise.
1017
+ safeWriteChunksSync(
1018
+ durablePath,
1019
+ combinedDosaiReportChunks(combined, [
1020
+ ["methods", methodsSource],
1021
+ ["dataflows", dataFlowSource],
1022
+ ]),
1023
+ );
1024
+ }
404
1025
  } catch (err) {
405
- // Combined methods + data-flow reports for large solutions can exceed the
406
- // JS maximum string length. The SBOM evidence is already collected by this
407
- // point, so skip the durable copy instead of losing the whole analysis.
408
- recordDegradation("dosai.semantics.persistFailed", {
1026
+ // A part with no native file can still be too large to serialise, and the
1027
+ // write can fail. The SBOM evidence is already collected by this point, so
1028
+ // skip the durable copy instead of losing the whole analysis.
1029
+ recordDegradation("dosai.semantics.persist-failed", {
409
1030
  ecosystem: "csharp",
410
1031
  tool: "dosai",
411
- impact: "minor",
1032
+ impact: "none",
412
1033
  command: `persistDosaiSemanticsReport ${durablePath}`,
413
1034
  detail: `The combined dosai report could not be persisted: ${err.message}. The SBOM evidence is unaffected.`,
414
1035
  });
@@ -421,21 +1042,63 @@ export function persistDosaiSemanticsReport(
421
1042
  * Build a purl alias map for a list of components.
422
1043
  *
423
1044
  * @param {Object[]} [components] Component objects with purl fields
424
- * @returns {Map<string, string>} Map of exact purls and normalized type/namespace/name keys to canonical purls
1045
+ * @param {Object} [options] Options passed to {@link buildDosaiPurlAliasMap} (`srcPath`)
1046
+ * @returns {Map<string, string>} Map of component purls, carrying the version-free identity index
425
1047
  */
426
- export function buildPurlAliasMap(components = []) {
427
- return buildDosaiPurlAliasMap(components);
1048
+ export function buildPurlAliasMap(components = [], options = {}) {
1049
+ return buildDosaiPurlAliasMap(components, options);
428
1050
  }
429
1051
 
430
1052
  /**
431
- * Resolve a possibly-aliased purl to the canonical component purl.
1053
+ * Resolve a dosai purl to the canonical component purl, by version, and for a
1054
+ * package the BOM holds in several versions by the record's location.
432
1055
  *
433
1056
  * @param {string} purl Purl from a dosai report
434
1057
  * @param {Map<string, string>} purlAliasMap Alias map built by buildPurlAliasMap
435
- * @returns {string|undefined} Canonical component purl, the input purl when unaliased, or undefined when empty
1058
+ * @param {string} [location] Source location of the dosai record
1059
+ * @returns {string|undefined} Canonical component purl, the input purl when the BOM has no such package,
1060
+ * or undefined when empty or ambiguous
1061
+ */
1062
+ export function resolveComponentPurl(purl, purlAliasMap, location) {
1063
+ return resolveDosaiComponentPurl(purl, purlAliasMap, location);
1064
+ }
1065
+
1066
+ /**
1067
+ * The component purls one PackageReachability fact belongs to: each of its
1068
+ * locations resolved in its own project (a versionless purl from unrestored
1069
+ * projects spans several), else the purl alone.
1070
+ *
1071
+ * @param {Object} reachability dosai PackageReachability entry
1072
+ * @param {Map<string, string>} purlAliasMap Alias map built by buildPurlAliasMap
1073
+ * @param {Object} [graph] Call graph lookups `{ edgesById, nodesById }`
1074
+ * @returns {string[]} Component purls
436
1075
  */
437
- export function resolveComponentPurl(purl, purlAliasMap) {
438
- return resolveDosaiComponentPurl(purl, purlAliasMap);
1076
+ function reachabilityComponentPurls(reachability, purlAliasMap, graph = {}) {
1077
+ const locations = [
1078
+ ...(reachability.SourceLocations || []).map((location) =>
1079
+ dosaiSourceLocation(location),
1080
+ ),
1081
+ ...(reachability.EdgeIds || []).map((edgeId) =>
1082
+ dosaiSourceLocation(graph.edgesById?.get(edgeId)),
1083
+ ),
1084
+ ...(reachability.NodeIds || []).map((nodeId) =>
1085
+ dosaiSourceLocationFromNode(graph.nodesById?.get(nodeId)),
1086
+ ),
1087
+ ].filter(Boolean);
1088
+ const purls = new Set(
1089
+ locations
1090
+ .map((location) =>
1091
+ resolveComponentPurl(reachability.Purl, purlAliasMap, location),
1092
+ )
1093
+ .filter(Boolean),
1094
+ );
1095
+ if (!purls.size) {
1096
+ const purl = resolveComponentPurl(reachability.Purl, purlAliasMap);
1097
+ if (purl) {
1098
+ purls.add(purl);
1099
+ }
1100
+ }
1101
+ return [...purls];
439
1102
  }
440
1103
 
441
1104
  /**
@@ -484,24 +1147,51 @@ export function addDosaiReachabilityProperties(component, reachability) {
484
1147
  *
485
1148
  * @param {Object} methodsSlice Parsed dosai methods slice JSON
486
1149
  * @param {Object[]} [components] BOM components used to resolve purl aliases and mutate
1150
+ * @param {Object} [options] Options
1151
+ * @param {string} [options.srcPath] Directory dosai analyzed; a package the BOM holds in several
1152
+ * versions is matched by the project of the record's file
487
1153
  * @returns {number} Number of distinct components enriched
488
1154
  */
489
- export function applyDosaiReachabilityEvidence(methodsSlice, components = []) {
490
- const purlAliasMap = buildPurlAliasMap(components);
1155
+ export function applyDosaiReachabilityEvidence(
1156
+ methodsSlice,
1157
+ components = [],
1158
+ options = {},
1159
+ ) {
1160
+ const purlAliasMap = buildPurlAliasMap(components, options);
491
1161
  const componentsByPurl = new Map(
492
1162
  components
493
1163
  .filter((component) => component?.purl)
494
1164
  .map((component) => [component.purl, component]),
495
1165
  );
1166
+ const graph = {
1167
+ edgesById: new Map(
1168
+ (methodsSlice?.CallGraph?.Edges || []).map((edge) => [edge.Id, edge]),
1169
+ ),
1170
+ nodesById: new Map(
1171
+ (methodsSlice?.CallGraph?.Nodes || []).map((node) => [node.Id, node]),
1172
+ ),
1173
+ };
496
1174
  const enriched = new Set();
497
- for (const reachability of methodsSlice?.PackageReachability || []) {
498
- const purl = resolveComponentPurl(reachability.Purl, purlAliasMap);
499
- const component = purl && componentsByPurl.get(purl);
500
- if (!component) {
501
- continue;
1175
+ // The first fact applied to a component wins, so a fact that names the
1176
+ // component's exact purl goes before one that reached it by name.
1177
+ const facts = [...(methodsSlice?.PackageReachability || [])].sort(
1178
+ (a, b) =>
1179
+ Number(!componentsByPurl.has(a.Purl)) -
1180
+ Number(!componentsByPurl.has(b.Purl)),
1181
+ );
1182
+ for (const reachability of facts) {
1183
+ for (const purl of reachabilityComponentPurls(
1184
+ reachability,
1185
+ purlAliasMap,
1186
+ graph,
1187
+ )) {
1188
+ const component = componentsByPurl.get(purl);
1189
+ if (!component) {
1190
+ continue;
1191
+ }
1192
+ addDosaiReachabilityProperties(component, reachability);
1193
+ enriched.add(component);
502
1194
  }
503
- addDosaiReachabilityProperties(component, reachability);
504
- enriched.add(component);
505
1195
  }
506
1196
  return enriched.size;
507
1197
  }
@@ -514,10 +1204,17 @@ export function applyDosaiReachabilityEvidence(methodsSlice, components = []) {
514
1204
  *
515
1205
  * @param {Object} methodsSlice Parsed dosai methods slice JSON
516
1206
  * @param {Object[]} [components] BOM components used to resolve purl aliases
1207
+ * @param {Object} [options] Options
1208
+ * @param {string} [options.srcPath] Directory dosai analyzed; a package the BOM holds in several
1209
+ * versions is matched by the project of the record's file
517
1210
  * @returns {Object} Object with purlLocationMap, purlModulesMap, and purlMethodsMap keyed by purl
518
1211
  */
519
- export function collectDosaiPurlEvidence(methodsSlice, components = []) {
520
- const purlAliasMap = buildPurlAliasMap(components);
1212
+ export function collectDosaiPurlEvidence(
1213
+ methodsSlice,
1214
+ components = [],
1215
+ options = {},
1216
+ ) {
1217
+ const purlAliasMap = buildPurlAliasMap(components, options);
521
1218
  const purlLocationMap = {};
522
1219
  const purlModulesMap = {};
523
1220
  const purlMethodsMap = {};
@@ -529,7 +1226,11 @@ export function collectDosaiPurlEvidence(methodsSlice, components = []) {
529
1226
  );
530
1227
 
531
1228
  for (const dependency of methodsSlice?.Dependencies || []) {
532
- const purl = resolveComponentPurl(dependency.Purl, purlAliasMap);
1229
+ const purl = resolveComponentPurl(
1230
+ dependency.Purl,
1231
+ purlAliasMap,
1232
+ dependency.Path,
1233
+ );
533
1234
  if (!purl) {
534
1235
  continue;
535
1236
  }
@@ -542,42 +1243,61 @@ export function collectDosaiPurlEvidence(methodsSlice, components = []) {
542
1243
  }
543
1244
 
544
1245
  for (const reachability of methodsSlice?.PackageReachability || []) {
545
- const purl = resolveComponentPurl(reachability.Purl, purlAliasMap);
546
- if (!purl) {
547
- continue;
548
- }
1246
+ // Every location resolves in its own project: a versionless purl from
1247
+ // unrestored projects spans projects restored at different versions.
1248
+ const resolveAt = (location) =>
1249
+ resolveComponentPurl(reachability.Purl, purlAliasMap, location);
1250
+ const factPurls = new Set();
1251
+ // Modules and methods of call-graph items that name no source file
1252
+ // (frames inside the package), held for the fact's own version.
1253
+ const unplaced = [];
549
1254
  let hasExplicitSourceLocations = false;
550
1255
  for (const sourceLocation of reachability.SourceLocations || []) {
551
1256
  const location = dosaiSourceLocation(sourceLocation);
1257
+ const purl = resolveAt(location);
552
1258
  addDosaiSetValue(purlLocationMap, purl, location);
1259
+ if (purl) {
1260
+ factPurls.add(purl);
1261
+ }
553
1262
  hasExplicitSourceLocations ||= Boolean(location);
554
1263
  }
555
1264
  for (const edgeId of reachability.EdgeIds || []) {
556
1265
  const edge = edgesById.get(edgeId);
1266
+ const location = dosaiSourceLocation(edge);
1267
+ const purl = resolveAt(location ?? edge?.Path);
1268
+ const method = edge?.CalledMethodName || edge?.TargetName;
1269
+ if (!purl) {
1270
+ unplaced.push([purlMethodsMap, method]);
1271
+ continue;
1272
+ }
1273
+ factPurls.add(purl);
557
1274
  if (!hasExplicitSourceLocations) {
558
- addDosaiSetValue(purlLocationMap, purl, dosaiSourceLocation(edge));
1275
+ addDosaiSetValue(purlLocationMap, purl, location);
559
1276
  }
560
- addDosaiSetValue(
561
- purlMethodsMap,
562
- purl,
563
- edge?.CalledMethodName || edge?.TargetName,
564
- );
1277
+ addDosaiSetValue(purlMethodsMap, purl, method);
565
1278
  }
566
1279
  for (const nodeId of reachability.NodeIds || []) {
567
1280
  const node = nodesById.get(nodeId);
1281
+ const location = dosaiSourceLocationFromNode(node);
1282
+ const purl = resolveAt(location);
1283
+ const module = node?.ClassName || node?.Module;
1284
+ const method = node?.Name || node?.Identity?.MethodName;
1285
+ if (!purl) {
1286
+ unplaced.push([purlModulesMap, module], [purlMethodsMap, method]);
1287
+ continue;
1288
+ }
1289
+ factPurls.add(purl);
568
1290
  if (!hasExplicitSourceLocations) {
569
- addDosaiSetValue(
570
- purlLocationMap,
571
- purl,
572
- dosaiSourceLocationFromNode(node),
573
- );
1291
+ addDosaiSetValue(purlLocationMap, purl, location);
1292
+ }
1293
+ addDosaiSetValue(purlModulesMap, purl, module);
1294
+ addDosaiSetValue(purlMethodsMap, purl, method);
1295
+ }
1296
+ if (factPurls.size === 1) {
1297
+ const [purl] = factPurls;
1298
+ for (const [map, value] of unplaced) {
1299
+ addDosaiSetValue(map, purl, value);
574
1300
  }
575
- addDosaiSetValue(purlModulesMap, purl, node?.ClassName || node?.Module);
576
- addDosaiSetValue(
577
- purlMethodsMap,
578
- purl,
579
- node?.Name || node?.Identity?.MethodName,
580
- );
581
1301
  }
582
1302
  }
583
1303
  return { purlLocationMap, purlModulesMap, purlMethodsMap };
@@ -591,17 +1311,38 @@ export function collectDosaiPurlEvidence(methodsSlice, components = []) {
591
1311
  *
592
1312
  * @param {Object} dataFlowResult Parsed dosai data-flow slice JSON
593
1313
  * @param {Object[]} [components] BOM components used to resolve purl aliases
1314
+ * @param {Object} [options] Options
1315
+ * @param {string} [options.srcPath] Directory dosai analyzed; a package the BOM holds in several
1316
+ * versions is matched by the project of the record's file
594
1317
  * @returns {Object} Map of canonical purl to arrays of call-stack frame objects
595
1318
  */
596
- export function collectDosaiDataFlowFrames(dataFlowResult, components = []) {
597
- const purlAliasMap = buildPurlAliasMap(components);
1319
+ export function collectDosaiDataFlowFrames(
1320
+ dataFlowResult,
1321
+ components = [],
1322
+ options = {},
1323
+ ) {
1324
+ const purlAliasMap = buildPurlAliasMap(components, options);
598
1325
  const nodesById = new Map(
599
1326
  (dataFlowResult?.Nodes || []).map((node) => [node.Id, node]),
600
1327
  );
601
1328
  const dataFlowFrames = {};
602
1329
  const addFramesForPurl = (purl, frames) => {
603
- const componentPurl = resolveComponentPurl(purl, purlAliasMap);
604
- if (!componentPurl || !frames.length) {
1330
+ if (!frames.length) {
1331
+ return;
1332
+ }
1333
+ // The flow runs in the project of its first frame that settles one.
1334
+ let componentPurl;
1335
+ for (const frame of frames) {
1336
+ componentPurl = resolveComponentPurl(
1337
+ purl,
1338
+ purlAliasMap,
1339
+ frame.fullFilename,
1340
+ );
1341
+ if (componentPurl) {
1342
+ break;
1343
+ }
1344
+ }
1345
+ if (!componentPurl) {
605
1346
  return;
606
1347
  }
607
1348
  dataFlowFrames[componentPurl] ??= [];