@cdxgen/cdxgen 13.4.0 → 13.6.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 (305) hide show
  1. package/README.md +9 -9
  2. package/bin/cdxgen-secure.js +15 -0
  3. package/bin/cdxgen.js +29 -5
  4. package/bin/evinse.js +8 -0
  5. package/bin/hbom.js +15 -7
  6. package/bin/repl.js +29 -0
  7. package/bin/validate.js +6 -0
  8. package/data/cbomosdb-queries.json +13 -1
  9. package/data/component-tags.json +8 -0
  10. package/data/license-aliases.json +24 -2
  11. package/data/queries-darwin.json +25 -1
  12. package/data/queries-win.json +19 -1
  13. package/data/queries.json +41 -0
  14. package/data/remediations.json +407 -1
  15. package/data/rules/ai-bom.yaml +1 -1
  16. package/data/rules/brotli-decoder.yaml +107 -0
  17. package/data/rules/cbom-security.yaml +235 -4
  18. package/data/rules/go-binaries.yaml +46 -0
  19. package/data/rules/golem-go.yaml +80 -0
  20. package/data/rules/hbom-compliance.yaml +40 -22
  21. package/data/rules/hbom-performance.yaml +7 -1
  22. package/data/rules/host-topology.yaml +1 -1
  23. package/data/rules/obom-runtime.yaml +31 -2
  24. package/data/rules/php-runtime.yaml +143 -0
  25. package/data/rules/vscode-extensions.yaml +75 -0
  26. package/data/spdx-license-list.json +121 -7
  27. package/data/spdx-licenses.json +69 -0
  28. package/data/spdx.schema.json +845 -2
  29. package/lib/audit/index.js +14 -0
  30. package/lib/cli/bomAssembly.js +120 -6
  31. package/lib/cli/cliOptions.js +110 -3
  32. package/lib/cli/index.js +182 -6
  33. package/lib/cli/jsBom.js +153 -64
  34. package/lib/cli/jvmBom.js +363 -66
  35. package/lib/cli/managedBom.js +853 -87
  36. package/lib/cli/nativeBom.js +267 -33
  37. package/lib/core/boundedFile.js +344 -0
  38. package/lib/core/brotliProbe.js +182 -0
  39. package/lib/core/env.js +38 -8
  40. package/lib/core/fs.js +40 -19
  41. package/lib/core/httpClient.js +172 -46
  42. package/lib/core/lockPaths.js +38 -0
  43. package/lib/core/paths.js +24 -1
  44. package/lib/core/processTree.js +5 -0
  45. package/lib/core/projectConfig.js +1 -0
  46. package/lib/core/projectTooling.js +97 -0
  47. package/lib/core/propertySanitizer.js +72 -0
  48. package/lib/core/toolRegistry.js +170 -0
  49. package/lib/ecosystems/cppEvidence.js +263 -106
  50. package/lib/ecosystems/cppIncludeResolver.js +16 -5
  51. package/lib/ecosystems/ecosystems.js +25 -10
  52. package/lib/ecosystems/gemHostProbe.js +301 -0
  53. package/lib/ecosystems/golem.js +550 -6
  54. package/lib/ecosystems/gradleutils.js +858 -244
  55. package/lib/ecosystems/parsers-bazel.js +107 -6
  56. package/lib/ecosystems/parsers-dotnet.js +117 -18
  57. package/lib/ecosystems/parsers-go.js +1090 -367
  58. package/lib/ecosystems/parsers-js.js +505 -22
  59. package/lib/ecosystems/parsers-jvm.js +259 -17
  60. package/lib/ecosystems/parsers-misc.js +438 -64
  61. package/lib/ecosystems/parsers-python.js +100 -12
  62. package/lib/ecosystems/pythonutils.js +128 -2
  63. package/lib/ecosystems/rubyutils.js +544 -34
  64. package/lib/ecosystems/rusi.js +10 -0
  65. package/lib/ecosystems/scalacliutils.js +4 -1
  66. package/lib/ecosystems/scalasem.js +798 -151
  67. package/lib/ecosystems/vendoredCode.js +237 -3
  68. package/lib/ecosystems/venvProbe.js +360 -0
  69. package/lib/evinser/evinser.js +472 -61
  70. package/lib/evinser/swiftsem.js +68 -5
  71. package/lib/helpers/agenticToolutils.js +413 -93
  72. package/lib/helpers/vsixutils.js +1574 -315
  73. package/lib/inventory/agentSkillSpec.js +17 -0
  74. package/lib/inventory/apkInstalledDb.js +243 -0
  75. package/lib/inventory/atomUtils.js +110 -0
  76. package/lib/inventory/auditCategories.js +3 -3
  77. package/lib/inventory/brotliDecoder.js +142 -0
  78. package/lib/inventory/buildToolRateLimit.js +1 -1
  79. package/lib/inventory/cacheDir.js +52 -9
  80. package/lib/inventory/cbomutils.js +56 -7
  81. package/lib/inventory/certificates.js +2371 -0
  82. package/lib/inventory/communityAiConfigParser.js +4 -9
  83. package/lib/inventory/deps.js +41 -4
  84. package/lib/inventory/display.js +200 -3
  85. package/lib/inventory/dosai.js +639 -63
  86. package/lib/inventory/dotnetRuntime.js +217 -0
  87. package/lib/inventory/dynamic.js +45 -3
  88. package/lib/inventory/envcontext.js +475 -18
  89. package/lib/inventory/evidenceUtils.js +447 -23
  90. package/lib/inventory/fetchBatch.js +1 -1
  91. package/lib/inventory/formulationParsers.js +32 -3
  92. package/lib/inventory/goBuildInfo.js +140 -0
  93. package/lib/inventory/gtfobins.js +13 -0
  94. package/lib/inventory/hbom.js +67 -7
  95. package/lib/inventory/hbomAnalysis.js +16 -0
  96. package/lib/inventory/hostRuntimeProbe.js +686 -0
  97. package/lib/inventory/hostTopology.js +21 -0
  98. package/lib/inventory/jvmLocalRepos.js +9 -2
  99. package/lib/inventory/jvmToolEnv.js +29 -0
  100. package/lib/inventory/licenseEnhancer.js +12 -0
  101. package/lib/inventory/mavenCentralMirror.js +534 -0
  102. package/lib/inventory/nodeBuildInfo.js +218 -0
  103. package/lib/inventory/nodeHostProbe.js +202 -0
  104. package/lib/inventory/osPackagePurl.js +244 -0
  105. package/lib/inventory/osPackageResolver.js +271 -87
  106. package/lib/inventory/osinfo.js +79 -31
  107. package/lib/inventory/osqueryTransform.js +114 -5
  108. package/lib/inventory/phpBuildInfo.js +438 -0
  109. package/lib/inventory/phpHostProbe.js +185 -0
  110. package/lib/inventory/rootfsCerts.js +426 -0
  111. package/lib/inventory/rubyBuildInfo.js +972 -0
  112. package/lib/inventory/rubyHostProbe.js +218 -0
  113. package/lib/inventory/scalaCoords.js +4 -3
  114. package/lib/inventory/scalaDedupe.js +440 -0
  115. package/lib/inventory/source.js +256 -5
  116. package/lib/inventory/spdx.js +144 -0
  117. package/lib/inventory/traceRunner.js +5 -1
  118. package/lib/inventory/tracedCrypto.js +129 -0
  119. package/lib/managers/binary.js +866 -186
  120. package/lib/managers/docker.js +245 -12
  121. package/lib/parsers/email.js +277 -0
  122. package/lib/parsers/largeJson.js +62 -13
  123. package/lib/parsers/vscodeExtensionId.js +183 -0
  124. package/lib/server/server.js +198 -2
  125. package/lib/stages/postgen/annotator.js +17 -1
  126. package/lib/stages/postgen/introspection/reflect.js +4 -1
  127. package/lib/stages/postgen/postgen.js +5 -5
  128. package/lib/stages/postgen/ruleEngine.js +71 -6
  129. package/lib/stages/postgen/specVersionCompat.js +30 -0
  130. package/lib/stages/pregen/envAudit.js +1 -0
  131. package/lib/stages/pregen/pregen.js +231 -62
  132. package/lib/validator/bomValidator.js +32 -6
  133. package/lib/validator/generated/runtime.mjs +13 -0
  134. package/lib/validator/generated/validate-1.6.mjs +27 -8
  135. package/lib/validator/generated/validate-1.7.mjs +32 -13
  136. package/lib/validator/generated/validate-2.0.mjs +31 -12
  137. package/lib/validator/index.js +1 -1
  138. package/package.json +19 -18
  139. package/types/bin/cdxgen-secure.d.ts +3 -0
  140. package/types/bin/cdxgen-secure.d.ts.map +1 -0
  141. package/types/bin/repl.d.ts.map +1 -1
  142. package/types/lib/audit/index.d.ts.map +1 -1
  143. package/types/lib/cli/bomAssembly.d.ts +22 -3
  144. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  145. package/types/lib/cli/cliOptions.d.ts +43 -0
  146. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  147. package/types/lib/cli/index.d.ts.map +1 -1
  148. package/types/lib/cli/jsBom.d.ts +1 -17
  149. package/types/lib/cli/jsBom.d.ts.map +1 -1
  150. package/types/lib/cli/jvmBom.d.ts +20 -0
  151. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  152. package/types/lib/cli/managedBom.d.ts.map +1 -1
  153. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  154. package/types/lib/core/boundedFile.d.ts +70 -0
  155. package/types/lib/core/boundedFile.d.ts.map +1 -0
  156. package/types/lib/core/brotliProbe.d.ts +37 -0
  157. package/types/lib/core/brotliProbe.d.ts.map +1 -0
  158. package/types/lib/core/env.d.ts +22 -2
  159. package/types/lib/core/env.d.ts.map +1 -1
  160. package/types/lib/core/fs.d.ts.map +1 -1
  161. package/types/lib/core/httpClient.d.ts +24 -0
  162. package/types/lib/core/httpClient.d.ts.map +1 -1
  163. package/types/lib/core/lockPaths.d.ts +19 -0
  164. package/types/lib/core/lockPaths.d.ts.map +1 -0
  165. package/types/lib/core/processTree.d.ts +2 -0
  166. package/types/lib/core/processTree.d.ts.map +1 -1
  167. package/types/lib/core/projectConfig.d.ts.map +1 -1
  168. package/types/lib/core/projectTooling.d.ts +41 -0
  169. package/types/lib/core/projectTooling.d.ts.map +1 -0
  170. package/types/lib/core/propertySanitizer.d.ts +26 -0
  171. package/types/lib/core/propertySanitizer.d.ts.map +1 -1
  172. package/types/lib/core/toolRegistry.d.ts +36 -0
  173. package/types/lib/core/toolRegistry.d.ts.map +1 -0
  174. package/types/lib/ecosystems/cppEvidence.d.ts +40 -13
  175. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  176. package/types/lib/ecosystems/cppIncludeResolver.d.ts +2 -1
  177. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -1
  178. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  179. package/types/lib/ecosystems/gemHostProbe.d.ts +28 -0
  180. package/types/lib/ecosystems/gemHostProbe.d.ts.map +1 -0
  181. package/types/lib/ecosystems/golem.d.ts +81 -1
  182. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  183. package/types/lib/ecosystems/gradleutils.d.ts +57 -21
  184. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  185. package/types/lib/ecosystems/parsers-bazel.d.ts.map +1 -1
  186. package/types/lib/ecosystems/parsers-dotnet.d.ts +40 -4
  187. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  188. package/types/lib/ecosystems/parsers-go.d.ts +85 -13
  189. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  190. package/types/lib/ecosystems/parsers-js.d.ts +17 -20
  191. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  192. package/types/lib/ecosystems/parsers-jvm.d.ts +28 -2
  193. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  194. package/types/lib/ecosystems/parsers-misc.d.ts +37 -58
  195. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  196. package/types/lib/ecosystems/parsers-python.d.ts +14 -3
  197. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  198. package/types/lib/ecosystems/pythonutils.d.ts +19 -1
  199. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  200. package/types/lib/ecosystems/rubyutils.d.ts +144 -12
  201. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  202. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  203. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
  204. package/types/lib/ecosystems/scalasem.d.ts.map +1 -1
  205. package/types/lib/ecosystems/vendoredCode.d.ts +26 -0
  206. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -1
  207. package/types/lib/ecosystems/venvProbe.d.ts +40 -0
  208. package/types/lib/ecosystems/venvProbe.d.ts.map +1 -0
  209. package/types/lib/evinser/evinser.d.ts +88 -8
  210. package/types/lib/evinser/evinser.d.ts.map +1 -1
  211. package/types/lib/evinser/swiftsem.d.ts +10 -0
  212. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  213. package/types/lib/inventory/agentSkillSpec.d.ts +13 -0
  214. package/types/lib/inventory/agentSkillSpec.d.ts.map +1 -0
  215. package/types/lib/inventory/apkInstalledDb.d.ts +42 -0
  216. package/types/lib/inventory/apkInstalledDb.d.ts.map +1 -0
  217. package/types/lib/inventory/atomUtils.d.ts +33 -0
  218. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  219. package/types/lib/inventory/brotliDecoder.d.ts +58 -0
  220. package/types/lib/inventory/brotliDecoder.d.ts.map +1 -0
  221. package/types/lib/inventory/cacheDir.d.ts +12 -4
  222. package/types/lib/inventory/cacheDir.d.ts.map +1 -1
  223. package/types/lib/inventory/cbomutils.d.ts +17 -0
  224. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  225. package/types/lib/inventory/certificates.d.ts +205 -0
  226. package/types/lib/inventory/certificates.d.ts.map +1 -0
  227. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  228. package/types/lib/inventory/deps.d.ts +2 -1
  229. package/types/lib/inventory/deps.d.ts.map +1 -1
  230. package/types/lib/inventory/display.d.ts +18 -0
  231. package/types/lib/inventory/display.d.ts.map +1 -1
  232. package/types/lib/inventory/dosai.d.ts +162 -7
  233. package/types/lib/inventory/dosai.d.ts.map +1 -1
  234. package/types/lib/inventory/dotnetRuntime.d.ts +44 -0
  235. package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -0
  236. package/types/lib/inventory/dynamic.d.ts +6 -1
  237. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  238. package/types/lib/inventory/envcontext.d.ts +92 -8
  239. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  240. package/types/lib/inventory/evidenceUtils.d.ts +29 -10
  241. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  242. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  243. package/types/lib/inventory/goBuildInfo.d.ts +10 -0
  244. package/types/lib/inventory/goBuildInfo.d.ts.map +1 -0
  245. package/types/lib/inventory/gtfobins.d.ts.map +1 -1
  246. package/types/lib/inventory/hbom.d.ts.map +1 -1
  247. package/types/lib/inventory/hbomAnalysis.d.ts +10 -0
  248. package/types/lib/inventory/hbomAnalysis.d.ts.map +1 -1
  249. package/types/lib/inventory/hostRuntimeProbe.d.ts +44 -0
  250. package/types/lib/inventory/hostRuntimeProbe.d.ts.map +1 -0
  251. package/types/lib/inventory/hostTopology.d.ts.map +1 -1
  252. package/types/lib/inventory/jvmToolEnv.d.ts +12 -0
  253. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
  254. package/types/lib/inventory/licenseEnhancer.d.ts.map +1 -1
  255. package/types/lib/inventory/mavenCentralMirror.d.ts +170 -0
  256. package/types/lib/inventory/mavenCentralMirror.d.ts.map +1 -0
  257. package/types/lib/inventory/nodeBuildInfo.d.ts +95 -0
  258. package/types/lib/inventory/nodeBuildInfo.d.ts.map +1 -0
  259. package/types/lib/inventory/nodeHostProbe.d.ts +15 -0
  260. package/types/lib/inventory/nodeHostProbe.d.ts.map +1 -0
  261. package/types/lib/inventory/osPackagePurl.d.ts +99 -0
  262. package/types/lib/inventory/osPackagePurl.d.ts.map +1 -0
  263. package/types/lib/inventory/osPackageResolver.d.ts +44 -2
  264. package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
  265. package/types/lib/inventory/osinfo.d.ts +31 -6
  266. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  267. package/types/lib/inventory/osqueryTransform.d.ts +51 -0
  268. package/types/lib/inventory/osqueryTransform.d.ts.map +1 -1
  269. package/types/lib/inventory/phpBuildInfo.d.ts +142 -0
  270. package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -0
  271. package/types/lib/inventory/phpHostProbe.d.ts +35 -0
  272. package/types/lib/inventory/phpHostProbe.d.ts.map +1 -0
  273. package/types/lib/inventory/rootfsCerts.d.ts +27 -0
  274. package/types/lib/inventory/rootfsCerts.d.ts.map +1 -0
  275. package/types/lib/inventory/rubyBuildInfo.d.ts +271 -0
  276. package/types/lib/inventory/rubyBuildInfo.d.ts.map +1 -0
  277. package/types/lib/inventory/rubyHostProbe.d.ts +47 -0
  278. package/types/lib/inventory/rubyHostProbe.d.ts.map +1 -0
  279. package/types/lib/inventory/source.d.ts +38 -0
  280. package/types/lib/inventory/source.d.ts.map +1 -1
  281. package/types/lib/inventory/spdx.d.ts +15 -0
  282. package/types/lib/inventory/spdx.d.ts.map +1 -1
  283. package/types/lib/inventory/traceRunner.d.ts +4 -1
  284. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  285. package/types/lib/inventory/tracedCrypto.d.ts +22 -0
  286. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -1
  287. package/types/lib/managers/binary.d.ts +10 -0
  288. package/types/lib/managers/binary.d.ts.map +1 -1
  289. package/types/lib/managers/docker.d.ts +24 -0
  290. package/types/lib/managers/docker.d.ts.map +1 -1
  291. package/types/lib/parsers/email.d.ts +19 -0
  292. package/types/lib/parsers/email.d.ts.map +1 -0
  293. package/types/lib/parsers/largeJson.d.ts +17 -3
  294. package/types/lib/parsers/largeJson.d.ts.map +1 -1
  295. package/types/lib/server/server.d.ts +8 -0
  296. package/types/lib/server/server.d.ts.map +1 -1
  297. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  298. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -1
  299. package/types/lib/stages/postgen/ruleEngine.d.ts +7 -4
  300. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  301. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  302. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  303. package/types/lib/stages/pregen/pregen.d.ts +26 -9
  304. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  305. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,972 @@
1
+ // Bounded Ruby build-version reader for unpackaged Ruby installs, and the one
2
+ // place a Ruby version becomes a runtime component, for the container scan,
3
+ // the OS scan, formulation and the source scan alike.
4
+ //
5
+ // The Ruby of a Docker Official image, ruby-build, rbenv, rvm, asdf, mise,
6
+ // chruby and the RubyInstaller is owned by no package manager, so a BOM sees it
7
+ // only as files. Its version decides whether the interpreter is affected by
8
+ // advisories in its core, such as the Onigmo fixes of Ruby 3.3.13, 3.4.12 and
9
+ // 4.0.9 (CVE-2026-89078, CVE-2026-93577, CVE-2026-97229, CVE-2026-110264).
10
+ //
11
+ // Three facts identify a build, read in this order:
12
+ // - rbconfig.rb, which `make install` writes under
13
+ // <prefix>/lib/ruby/<abi>/<arch>/ (or <prefix>/lib/<triplet>/ruby/<abi>/ in
14
+ // a Debian layout). RUBY_PROGRAM_VERSION in it is the unexpanded template
15
+ // "$(MAJOR).$(MINOR).$(TEENY)", so the version is assembled from the MAJOR,
16
+ // MINOR and TEENY entries, with PATCHLEVEL beside them:
17
+ // https://github.com/ruby/ruby/blob/v3_3_6/tool/mkconfig.rb
18
+ // - the description string compiled into libruby (or a static `ruby`), the
19
+ // first line `ruby -v` prints: `ruby 3.3.6 (2024-11-05 revision 75015d4c1f)
20
+ // [x86_64-linux]`. It also names a preview or release candidate, which a
21
+ // PATCHLEVEL of -1 in rbconfig.rb leaves unsaid:
22
+ // https://github.com/ruby/ruby/blob/v3_3_6/version.c
23
+ // - the libruby soname, libruby.so.3.3.6, when nothing else could be read.
24
+ //
25
+ // Every file in an image or on a host is hostile: only regular files inside
26
+ // the root are opened, reads are capped, nothing is executed, and a missing or
27
+ // odd file yields no result rather than throwing.
28
+
29
+ import { readdirSync, statSync } from "node:fs";
30
+ import { basename, join } from "node:path";
31
+
32
+ import { readRegularTextFile, scanFileMarkers } from "../core/boundedFile.js";
33
+ import { applyPurl, tryBuildPurl } from "./purl.js";
34
+
35
+ /**
36
+ * The most Ruby-named files one scan opens in search of a build version.
37
+ *
38
+ * @type {number}
39
+ */
40
+ export const MAX_RUBY_PROBES = 64;
41
+
42
+ const MAX_RBCONFIG_BYTES = 512 * 1024;
43
+ const MAX_LISTED_DIRECTORIES = 32;
44
+ const MAX_VERSION_LENGTH = 32;
45
+ const DESCRIPTION_MARKER = Buffer.from("ruby ");
46
+
47
+ // MRI versions: 3.3.6, 3.4.0-preview2 (also written 3.4.0preview2), 3.4.0-rc1,
48
+ // 3.5.0dev. A patchlevel suffix (p108) belongs to the description, not the
49
+ // version.
50
+ const VERSION_RE = /^(\d+\.\d+\.\d+)(?:-?(preview|rc)(\d+)|(dev))?$/;
51
+ // The Ruby ABI directory: 3.3.0, or 3.5.0+0 for a development build.
52
+ const ABI_RE = /^\d+\.\d+\.\d+(?:\+\d+)?$/;
53
+ const RBCONFIG_ENTRY_RE = /^\s*CONFIG\["([A-Za-z_]+)"\]\s*=\s*"([^"\\]*)"\s*$/;
54
+ const RBCONFIG_KEYS = new Set([
55
+ "MAJOR",
56
+ "MINOR",
57
+ "TEENY",
58
+ "PATCHLEVEL",
59
+ "ruby_version",
60
+ "arch",
61
+ "RUBY_BASE_NAME",
62
+ "RUBY_PROGRAM_VERSION",
63
+ "RUBY_SO_NAME",
64
+ ]);
65
+
66
+ /**
67
+ * Parse an MRI version and normalize a pre-release to the hyphenated form the
68
+ * release tarballs use: `3.4.0preview2` becomes `3.4.0-preview2`.
69
+ *
70
+ * @param {string} text candidate version, without a patchlevel
71
+ * @returns {string|undefined} the version, or undefined
72
+ */
73
+ export function parseRubyVersion(text) {
74
+ if (typeof text !== "string" || !text || text.length > MAX_VERSION_LENGTH) {
75
+ return undefined;
76
+ }
77
+ const match = VERSION_RE.exec(text.trim());
78
+ if (!match) {
79
+ return undefined;
80
+ }
81
+ if (match[2]) {
82
+ return `${match[1]}-${match[2]}${match[3]}`;
83
+ }
84
+ return match[4] ? `${match[1]}-dev` : match[1];
85
+ }
86
+
87
+ /**
88
+ * Read the first line `ruby -v` prints, or the description compiled into
89
+ * libruby, for MRI, JRuby and TruffleRuby:
90
+ * - `ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [x86_64-linux]`
91
+ * - `ruby 2.7.8p225 (2023-03-30 revision 1f4d455848) [x86_64-linux]`
92
+ * - `ruby 3.4.0preview2 (2024-10-07 master 32c733f57b) +PRISM [x86_64-linux]`
93
+ * - `jruby 9.4.8.0 (3.1.4) 2024-07-02 4d41e55a67 OpenJDK 64-Bit ...`
94
+ * - `truffleruby 24.1.0, like ruby 3.2.4, Oracle GraalVM Native [x86_64-linux]`
95
+ *
96
+ * @param {string} text description line
97
+ * @returns {{engine: string, version: string, patchlevel?: string,
98
+ * rubyVersion?: string, platform?: string}|undefined} engine is `ruby`,
99
+ * `jruby` or `truffleruby`; for the last two, rubyVersion is the Ruby
100
+ * language version the engine implements
101
+ */
102
+ export function parseRubyDescription(text) {
103
+ if (typeof text !== "string") {
104
+ return undefined;
105
+ }
106
+ const line = text.split(/\r?\n/, 1)[0].trim().slice(0, 256);
107
+ const space = line.indexOf(" ");
108
+ if (space <= 0) {
109
+ return undefined;
110
+ }
111
+ const engine = line.slice(0, space);
112
+ const rest = line.slice(space + 1);
113
+ if (engine === "ruby") {
114
+ const end = rest.indexOf(" ");
115
+ if (end <= 0 || rest[end + 1] !== "(") {
116
+ return undefined;
117
+ }
118
+ let token = rest.slice(0, end);
119
+ let patchlevel;
120
+ const pIndex = token.lastIndexOf("p");
121
+ if (pIndex > 0 && /^\d+$/.test(token.slice(pIndex + 1))) {
122
+ // `p108`, but not the `p` of `preview2`
123
+ const before = token.slice(0, pIndex);
124
+ if (parseRubyVersion(before)) {
125
+ patchlevel = token.slice(pIndex + 1);
126
+ token = before;
127
+ }
128
+ }
129
+ const version = parseRubyVersion(token);
130
+ if (!version) {
131
+ return undefined;
132
+ }
133
+ const result = { engine, version };
134
+ if (patchlevel !== undefined) {
135
+ result.patchlevel = patchlevel;
136
+ }
137
+ const open = rest.lastIndexOf("[");
138
+ const close = rest.lastIndexOf("]");
139
+ if (open !== -1 && close > open + 1) {
140
+ result.platform = rest.slice(open + 1, close);
141
+ }
142
+ return result;
143
+ }
144
+ if (engine === "jruby") {
145
+ const match = /^(\d+(?:\.\d+){2,3}) \((\d+\.\d+\.\d+)\)/.exec(rest);
146
+ return match
147
+ ? { engine, version: match[1], rubyVersion: match[2] }
148
+ : undefined;
149
+ }
150
+ if (engine === "truffleruby") {
151
+ const match = /^(\d+(?:\.\d+){1,3}), like ruby (\d+\.\d+\.\d+)/.exec(rest);
152
+ return match
153
+ ? { engine, version: match[1], rubyVersion: match[2] }
154
+ : undefined;
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ /**
160
+ * Read the entries of an rbconfig.rb that identify a build. Only literal
161
+ * `CONFIG["KEY"] = "value"` lines are read; Ruby is never evaluated.
162
+ *
163
+ * @param {string} text rbconfig.rb contents
164
+ * @returns {{version: string, patchlevel?: string, abi?: string,
165
+ * arch?: string, baseName?: string}|undefined}
166
+ */
167
+ export function parseRbConfig(text) {
168
+ if (typeof text !== "string" || !text) {
169
+ return undefined;
170
+ }
171
+ const entries = Object.create(null);
172
+ for (const line of text.split("\n")) {
173
+ if (!line.includes("CONFIG[")) {
174
+ continue;
175
+ }
176
+ const match = RBCONFIG_ENTRY_RE.exec(line);
177
+ if (match && RBCONFIG_KEYS.has(match[1]) && !(match[1] in entries)) {
178
+ entries[match[1]] = match[2];
179
+ }
180
+ }
181
+ const { MAJOR, MINOR, TEENY } = entries;
182
+ let version;
183
+ if ([MAJOR, MINOR, TEENY].every((part) => /^\d+$/.test(part || ""))) {
184
+ version = `${MAJOR}.${MINOR}.${TEENY}`;
185
+ } else {
186
+ version = parseRubyVersion(entries.RUBY_PROGRAM_VERSION);
187
+ }
188
+ if (!version) {
189
+ return undefined;
190
+ }
191
+ const result = { version };
192
+ if (/^-?\d+$/.test(entries.PATCHLEVEL || "")) {
193
+ result.patchlevel = entries.PATCHLEVEL;
194
+ }
195
+ if (ABI_RE.test(entries.ruby_version || "")) {
196
+ result.abi = entries.ruby_version;
197
+ }
198
+ if (entries.arch && entries.arch.length <= 64) {
199
+ result.arch = entries.arch;
200
+ }
201
+ if (entries.RUBY_BASE_NAME && entries.RUBY_BASE_NAME.length <= 32) {
202
+ result.baseName = entries.RUBY_BASE_NAME;
203
+ }
204
+ return result;
205
+ }
206
+
207
+ /**
208
+ * Whether a file name is one a Ruby interpreter is installed under: `ruby`,
209
+ * a versioned `ruby3.3` as distros and `--program-suffix` builds name it,
210
+ * `ruby.exe` and `rubyw.exe`.
211
+ *
212
+ * @param {string} fileName basename or path of the candidate
213
+ * @returns {boolean}
214
+ */
215
+ export function isRubyBinaryName(fileName) {
216
+ if (typeof fileName !== "string" || !fileName) {
217
+ return false;
218
+ }
219
+ const name = basename(fileName.replaceAll("\\", "/"));
220
+ const lower = name.toLowerCase();
221
+ return (
222
+ /^ruby(\d+(\.\d+){0,2})?$/.test(name) ||
223
+ lower === "ruby.exe" ||
224
+ lower === "rubyw.exe"
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Whether a file name is the shared Ruby library: libruby.so.3.3.6, Debian's
230
+ * libruby-3.1.so.3.1.2,
231
+ * libruby.3.3.dylib, or the RubyInstaller's x64-ucrt-ruby340.dll.
232
+ *
233
+ * @param {string} fileName basename or path of the candidate
234
+ * @returns {boolean}
235
+ */
236
+ export function isLibrubyName(fileName) {
237
+ if (typeof fileName !== "string" || !fileName) {
238
+ return false;
239
+ }
240
+ const name = basename(fileName.replaceAll("\\", "/"));
241
+ return (
242
+ /^libruby(-\d+\.\d+)?(-static)?\.so(\.\d+){0,3}$/.test(name) ||
243
+ /^libruby(\.\d+){0,3}\.dylib$/.test(name) ||
244
+ /^[a-z0-9_]+(-[a-z0-9_]+)?-ruby\d{2,3}\.dll$/i.test(name)
245
+ );
246
+ }
247
+
248
+ /**
249
+ * The version a libruby soname spells, libruby.so.3.3.6 -> 3.3.6.
250
+ *
251
+ * @param {string} fileName basename or path
252
+ * @returns {string|undefined}
253
+ */
254
+ export function versionFromLibrubyName(fileName) {
255
+ const name = basename(`${fileName || ""}`.replaceAll("\\", "/"));
256
+ const match = /^libruby(?:-\d+\.\d+)?\.so\.(\d+\.\d+\.\d+)$/.exec(name);
257
+ if (match) {
258
+ return match[1];
259
+ }
260
+ const dylib = /^libruby\.(\d+\.\d+\.\d+)\.dylib$/.exec(name);
261
+ return dylib ? dylib[1] : undefined;
262
+ }
263
+
264
+ // lib/ruby/<abi> holds hundreds of .rb files and dozens of library
265
+ // directories beside the one arch directory, so the cap applies to the
266
+ // directories accepted, after a bounded read.
267
+ const MAX_LISTED_ENTRIES = 8192;
268
+
269
+ function listDirectories(directory, accept = () => true) {
270
+ try {
271
+ const directories = [];
272
+ for (const name of readdirSync(directory)
273
+ .slice(0, MAX_LISTED_ENTRIES)
274
+ .sort()) {
275
+ if (name.endsWith(".rb") || !accept(name)) {
276
+ continue;
277
+ }
278
+ try {
279
+ if (statSync(join(directory, name)).isDirectory()) {
280
+ directories.push(name);
281
+ }
282
+ } catch {
283
+ // gone, or not readable
284
+ }
285
+ if (directories.length >= MAX_LISTED_DIRECTORIES) {
286
+ break;
287
+ }
288
+ }
289
+ return directories;
290
+ } catch {
291
+ return [];
292
+ }
293
+ }
294
+
295
+ function listFiles(directory) {
296
+ try {
297
+ return readdirSync(directory).sort();
298
+ } catch {
299
+ return [];
300
+ }
301
+ }
302
+
303
+ /**
304
+ * The install prefix of a file under bin, sbin or lib: /usr/local/bin/ruby ->
305
+ * /usr/local, C:\Ruby34-x64\bin\ruby.exe -> C:\Ruby34-x64.
306
+ *
307
+ * @param {string} absPath absolute path
308
+ * @returns {string|undefined}
309
+ */
310
+ export function rubyInstallPrefix(absPath) {
311
+ if (typeof absPath !== "string" || !absPath) {
312
+ return undefined;
313
+ }
314
+ // Paths from an image are POSIX whatever the host is, so the separator is
315
+ // read from the path rather than taken from the platform.
316
+ const separator =
317
+ absPath.includes("\\") && !absPath.includes("/") ? "\\" : "/";
318
+ const parts = absPath.replaceAll("\\", "/").split("/");
319
+ for (let i = parts.length - 2; i >= 0; i--) {
320
+ const part = parts[i].toLowerCase();
321
+ if (part === "bin" || part === "sbin" || part === "lib") {
322
+ return parts.slice(0, i).join(separator) || separator;
323
+ }
324
+ }
325
+ return undefined;
326
+ }
327
+
328
+ /**
329
+ * The rbconfig.rb files an install prefix holds, at most a few levels down:
330
+ * <prefix>/lib/ruby/<abi>/<arch>/rbconfig.rb, and the Debian layout
331
+ * <prefix>/lib/<triplet>/ruby/<abi>/rbconfig.rb.
332
+ *
333
+ * @param {string} prefix install prefix
334
+ * @returns {string[]} absolute paths
335
+ */
336
+ export function findRbConfigFiles(prefix) {
337
+ const found = [];
338
+ const libDir = join(prefix, "lib");
339
+ const rubyDir = join(libDir, "ruby");
340
+ for (const abi of listDirectories(rubyDir, (name) => ABI_RE.test(name))) {
341
+ if (!ABI_RE.test(abi)) {
342
+ continue;
343
+ }
344
+ // An arch is cpu-os (x86_64-linux, x64-mingw-ucrt, arm64-darwin24); the
345
+ // library directories beside it (bigdecimal, did_you_mean) have no dash.
346
+ for (const arch of listDirectories(join(rubyDir, abi), (name) =>
347
+ name.includes("-"),
348
+ )) {
349
+ const candidate = join(rubyDir, abi, arch, "rbconfig.rb");
350
+ if (listFiles(join(rubyDir, abi, arch)).includes("rbconfig.rb")) {
351
+ found.push(candidate);
352
+ }
353
+ }
354
+ }
355
+ for (const triplet of listDirectories(libDir, (name) => name.includes("-"))) {
356
+ if (triplet === "ruby" || !triplet.includes("-")) {
357
+ continue;
358
+ }
359
+ for (const abi of listDirectories(join(libDir, triplet, "ruby"))) {
360
+ if (!ABI_RE.test(abi)) {
361
+ continue;
362
+ }
363
+ if (
364
+ listFiles(join(libDir, triplet, "ruby", abi)).includes("rbconfig.rb")
365
+ ) {
366
+ found.push(join(libDir, triplet, "ruby", abi, "rbconfig.rb"));
367
+ }
368
+ }
369
+ }
370
+ return found;
371
+ }
372
+
373
+ // The major.minor a file name pins: ruby3.3 -> 3.3, libruby.so.3.3.6 -> 3.3,
374
+ // x64-ucrt-ruby330.dll -> 3.3.
375
+ function pinnedSeries(fileName) {
376
+ const name = basename(`${fileName || ""}`.replaceAll("\\", "/"));
377
+ let match = /^ruby(\d+)\.(\d+)/.exec(name);
378
+ if (!match) {
379
+ match = /^libruby(?:-\d+\.\d+)?(?:-static)?\.so\.(\d+)\.(\d+)/.exec(name);
380
+ }
381
+ if (!match) {
382
+ match = /^libruby\.(\d+)\.(\d+)/.exec(name);
383
+ }
384
+ if (!match) {
385
+ match = /-ruby(\d)(\d)\d\.dll$/i.exec(name);
386
+ }
387
+ return match ? `${match[1]}.${match[2]}` : undefined;
388
+ }
389
+
390
+ function readRbConfigFile(file, rootDir) {
391
+ const text = readRegularTextFile(file, MAX_RBCONFIG_BYTES, rootDir);
392
+ return text ? parseRbConfig(text) : undefined;
393
+ }
394
+
395
+ /**
396
+ * Find the description compiled into a file, `ruby 3.3.6 (2024-11-05 ...`.
397
+ *
398
+ * @param {string} absPath absolute path of a ruby binary or libruby
399
+ * @param {string|undefined} rootDir extracted root
400
+ * @returns {ReturnType<typeof parseRubyDescription>}
401
+ */
402
+ export function readRubyDescription(absPath, rootDir) {
403
+ const scan = scanFileMarkers(absPath, rootDir, [DESCRIPTION_MARKER], {
404
+ contextBytes: 160,
405
+ beforeBytes: 1,
406
+ maxHits: 256,
407
+ });
408
+ if (!scan || scan.skipped) {
409
+ return undefined;
410
+ }
411
+ for (const hit of scan.hits[0]) {
412
+ // The description is a C string of its own, so a NUL precedes it.
413
+ if (hit.before.length && hit.before[0] !== 0) {
414
+ continue;
415
+ }
416
+ const after = hit.after;
417
+ const nul = after.indexOf(0);
418
+ const text = `ruby ${after.toString("latin1", 0, nul === -1 ? after.length : nul)}`;
419
+ const parsed = parseRubyDescription(text);
420
+ if (parsed?.engine === "ruby") {
421
+ return parsed;
422
+ }
423
+ }
424
+ return undefined;
425
+ }
426
+
427
+ // The libruby beside a ruby binary, where a shared build keeps the description.
428
+ function siblingLibraries(prefix) {
429
+ const libraries = [];
430
+ for (const directory of [join(prefix, "lib"), join(prefix, "bin")]) {
431
+ for (const name of listFiles(directory)) {
432
+ if (isLibrubyName(name)) {
433
+ libraries.push(join(directory, name));
434
+ }
435
+ }
436
+ }
437
+ return libraries.slice(0, 8);
438
+ }
439
+
440
+ /**
441
+ * Read the Ruby build version of a ruby binary or libruby.
442
+ *
443
+ * @param {string} absPath absolute path of the candidate
444
+ * @param {string|undefined} rootDir extracted root, when the file came from one
445
+ * @returns {{version: string, source: string, patchlevel?: string,
446
+ * abi?: string, arch?: string}|undefined} source is `rbconfig`, `binary`
447
+ * or `soname`
448
+ */
449
+ export function readRubyBuildVersion(absPath, rootDir) {
450
+ const prefix = rubyInstallPrefix(absPath);
451
+ const series = pinnedSeries(absPath);
452
+ let build;
453
+ let ambiguous = false;
454
+ if (prefix) {
455
+ const configs = findRbConfigFiles(prefix)
456
+ .map((file) => readRbConfigFile(file, rootDir))
457
+ .filter(Boolean)
458
+ .filter(
459
+ (config) =>
460
+ !series || config.version.split(".").slice(0, 2).join(".") === series,
461
+ );
462
+ const versions = new Set(configs.map((config) => config.version));
463
+ if (versions.size === 1) {
464
+ build = { ...configs[0], source: "rbconfig" };
465
+ }
466
+ // Two Rubies share the prefix and the name does not say which this is:
467
+ // only the file's own description can, not a sibling's.
468
+ ambiguous = versions.size > 1;
469
+ }
470
+ // A PATCHLEVEL of -1 is a preview, release candidate or development build,
471
+ // which only the description names. Two installs sharing a prefix also need
472
+ // the binary to tell them apart.
473
+ if (!build || build.patchlevel === "-1") {
474
+ const files = [
475
+ absPath,
476
+ ...(prefix && !ambiguous ? siblingLibraries(prefix) : []),
477
+ ];
478
+ for (const file of files) {
479
+ const description = readRubyDescription(file, rootDir);
480
+ if (!description) {
481
+ continue;
482
+ }
483
+ if (
484
+ build &&
485
+ description.version.split("-")[0] !== build.version.split("-")[0]
486
+ ) {
487
+ continue;
488
+ }
489
+ build = {
490
+ ...(build || {}),
491
+ version: description.version,
492
+ source: build ? build.source : "binary",
493
+ };
494
+ if (description.patchlevel !== undefined) {
495
+ build.patchlevel = description.patchlevel;
496
+ }
497
+ if (!build.arch && description.platform) {
498
+ build.arch = description.platform;
499
+ }
500
+ break;
501
+ }
502
+ }
503
+ if (!build) {
504
+ const fromName = versionFromLibrubyName(absPath);
505
+ if (fromName) {
506
+ build = { version: fromName, source: "soname" };
507
+ }
508
+ }
509
+ if (!build) {
510
+ return undefined;
511
+ }
512
+ const result = { version: build.version, source: build.source };
513
+ for (const key of ["patchlevel", "abi", "arch"]) {
514
+ if (build[key] !== undefined) {
515
+ result[key] = build[key];
516
+ }
517
+ }
518
+ return result;
519
+ }
520
+
521
+ const ENGINE_IDENTITY = {
522
+ ruby: { vendor: "ruby-lang", product: "ruby" },
523
+ jruby: { vendor: "jruby", product: "jruby" },
524
+ truffleruby: undefined,
525
+ };
526
+
527
+ /**
528
+ * The CPE that lets a downstream scanner match an interpreter version. NVD
529
+ * names MRI ruby-lang:ruby and keeps a pre-release in the update field, as in
530
+ * cpe:2.3:a:ruby-lang:ruby:3.4.0:preview1:*:*:*:*:*:*.
531
+ *
532
+ * @param {string} version interpreter version
533
+ * @param {string} [engine="ruby"] `ruby` or `jruby`
534
+ * @returns {string|undefined} CPE 2.3 string, or undefined for an engine NVD
535
+ * does not name
536
+ */
537
+ export function rubyCpe(version, engine = "ruby") {
538
+ const identity = ENGINE_IDENTITY[engine];
539
+ if (!identity || typeof version !== "string" || !version) {
540
+ return undefined;
541
+ }
542
+ let base = version;
543
+ let update = "*";
544
+ const dash = version.indexOf("-");
545
+ if (dash !== -1) {
546
+ base = version.slice(0, dash);
547
+ update = version.slice(dash + 1).toLowerCase();
548
+ }
549
+ // NVD names no development snapshot, so a dev build has no CPE to match.
550
+ if (
551
+ !/^\d+(\.\d+){1,3}$/.test(base) ||
552
+ !/^(\*|[a-z0-9]+)$/.test(update) ||
553
+ update === "dev"
554
+ ) {
555
+ return undefined;
556
+ }
557
+ return `cpe:2.3:a:${identity.vendor}:${identity.product}:${base}:${update}:*:*:*:*:*:*`;
558
+ }
559
+
560
+ /**
561
+ * A Ruby runtime is one `platform` component per distinct engine and version,
562
+ * like the PHP and Node.js runtime components. Its identity is
563
+ * `pkg:generic/ruby` (or `jruby`, `truffleruby`).
564
+ *
565
+ * @param {string} version interpreter version
566
+ * @param {Object} [options] options
567
+ * @param {boolean} [options.withCpe=true] false for a Ruby a distro packaged,
568
+ * which backports fixes under its old upstream version
569
+ * @param {string} [options.engine="ruby"] `ruby`, `jruby` or `truffleruby`
570
+ * @returns {Object} component with `purl`, `bom-ref` and, when one applies, `cpe`
571
+ */
572
+ export function createRubyRuntimeComponent(
573
+ version,
574
+ { withCpe = true, engine = "ruby" } = {},
575
+ ) {
576
+ const name = engine in ENGINE_IDENTITY ? engine : "ruby";
577
+ const component = { type: "platform", name, version };
578
+ if (withCpe) {
579
+ const cpe = rubyCpe(version, name);
580
+ if (cpe) {
581
+ component.cpe = cpe;
582
+ }
583
+ }
584
+ return applyPurl(component, tryBuildPurl({ type: "generic", name, version }));
585
+ }
586
+
587
+ /**
588
+ * The bom-ref of the runtime component for a version.
589
+ *
590
+ * @param {string} version interpreter version
591
+ * @param {string} [engine="ruby"] engine
592
+ * @returns {string|undefined} bom-ref, which is the runtime's purl
593
+ */
594
+ export function rubyRuntimeRef(version, engine = "ruby") {
595
+ return createRubyRuntimeComponent(version, { engine })["bom-ref"];
596
+ }
597
+
598
+ /**
599
+ * The properties a Ruby file component carries once its build is known. They
600
+ * belong on unpackaged `file` components only: a package-owned Ruby is
601
+ * identified by its package.
602
+ *
603
+ * @param {{version: string, source: string, patchlevel?: string, abi?: string, arch?: string}} build
604
+ * result of readRubyBuildVersion
605
+ * @returns {{name: string, value: string}[]} properties to append
606
+ */
607
+ export function rubyBuildProperties(build) {
608
+ const properties = [
609
+ { name: "cdx:ruby:buildVersion", value: build.version },
610
+ { name: "cdx:ruby:buildVersionSource", value: build.source },
611
+ ];
612
+ if (build.patchlevel !== undefined) {
613
+ properties.push({ name: "cdx:ruby:patchlevel", value: build.patchlevel });
614
+ }
615
+ if (build.abi) {
616
+ properties.push({ name: "cdx:ruby:abiVersion", value: build.abi });
617
+ }
618
+ if (build.arch) {
619
+ properties.push({ name: "cdx:ruby:platform", value: build.arch });
620
+ }
621
+ return properties;
622
+ }
623
+
624
+ /**
625
+ * Start collecting the runtime components and edges of one scan.
626
+ *
627
+ * @returns {{runtimeComponents: Object[], dependencies: Object[], runtimeRefs: Set<string>}}
628
+ */
629
+ export function createRubyRuntimeInventory() {
630
+ return { runtimeComponents: [], dependencies: [], runtimeRefs: new Set() };
631
+ }
632
+
633
+ /**
634
+ * Tag a Ruby file component with its build facts and link it to the runtime
635
+ * component of its version, creating that component on first use. Mutates the
636
+ * component in place.
637
+ *
638
+ * @param {{runtimeComponents: Object[], dependencies: Object[], runtimeRefs: Set<string>}} inventory
639
+ * accumulator from createRubyRuntimeInventory
640
+ * @param {Object} component file component, with a `properties` array
641
+ * @param {{version: string, source: string, patchlevel?: string, abi?: string, arch?: string}} build
642
+ * result of readRubyBuildVersion
643
+ */
644
+ export function attachRubyBuild(inventory, component, build) {
645
+ component.properties.push(...rubyBuildProperties(build));
646
+ // A PATCHLEVEL of -1 is a pre-release whose tag, when no description named
647
+ // it, is unknown: the bare version would claim the release's CPE.
648
+ const untaggedPrerelease =
649
+ build.patchlevel === "-1" && !build.version.includes("-");
650
+ const runtime = createRubyRuntimeComponent(build.version, {
651
+ withCpe: !untaggedPrerelease,
652
+ });
653
+ const runtimeRef = runtime["bom-ref"];
654
+ if (!runtimeRef) {
655
+ return;
656
+ }
657
+ if (!inventory.runtimeRefs.has(runtimeRef)) {
658
+ inventory.runtimeRefs.add(runtimeRef);
659
+ if (build.patchlevel !== undefined && build.patchlevel !== "-1") {
660
+ runtime.properties = [
661
+ { name: "cdx:ruby:patchlevel", value: build.patchlevel },
662
+ ];
663
+ }
664
+ inventory.runtimeComponents.push(runtime);
665
+ inventory.dependencies.push({ ref: runtimeRef, dependsOn: [] });
666
+ }
667
+ if (component["bom-ref"]) {
668
+ inventory.dependencies.push({
669
+ ref: component["bom-ref"],
670
+ dependsOn: [runtimeRef],
671
+ });
672
+ }
673
+ }
674
+
675
+ // The OS packages that hold the Ruby interpreter itself. The library package
676
+ // is preferred, since libruby holds the interpreter core (Onigmo included);
677
+ // the interpreter package stands in where a distro ships no library package.
678
+ const LIBRARY_PACKAGES = [
679
+ /^libruby\d+\.\d+(t64)?$/, // Debian, Ubuntu
680
+ /^ruby-libs$/, // RHEL, Fedora, Alpine
681
+ /^libruby\d+_\d+-\d+_\d+$/, // openSUSE
682
+ ];
683
+ const INTERPRETER_PACKAGES = [
684
+ /^ruby\d+\.\d+$/, // Debian, Ubuntu, openSUSE
685
+ /^ruby-\d+\.\d+$/, // Wolfi, Chainguard
686
+ /^ruby$/, // RHEL, Fedora, Alpine, Arch, Homebrew
687
+ /^ruby@\d+(\.\d+)?$/, // Homebrew versioned formulae
688
+ ];
689
+ const OS_PURL_TYPES = new Set([
690
+ "deb",
691
+ "rpm",
692
+ "apk",
693
+ "alpm",
694
+ "brew",
695
+ "chocolatey",
696
+ "swid",
697
+ ]);
698
+ // The RubyInstaller registers itself with Windows Installer as
699
+ // `Ruby 3.4.10-1-x64`, which an OBOM names pkg:swid/Ruby%2B3.4.10-1-x64.
700
+ // The DevKit edition registers as `Ruby 3.3.6-1-x64 with MSYS2`.
701
+ const RUBYINSTALLER_PROGRAM =
702
+ /^Ruby[ +](\d+\.\d+\.\d+)-\d+-(x64|x86|arm64)([ +]with[ +]MSYS2)?$/;
703
+ // Package managers that ship the upstream release as built, without
704
+ // backporting fixes under an old version, so the release's CPE applies.
705
+ const UPSTREAM_BUILD_PURL_TYPES = new Set(["brew", "chocolatey", "swid"]);
706
+
707
+ function osPurlType(component) {
708
+ const purl = component?.purl;
709
+ if (typeof purl !== "string" || !purl.startsWith("pkg:")) {
710
+ return undefined;
711
+ }
712
+ const type = purl.slice(4, purl.indexOf("/"));
713
+ return OS_PURL_TYPES.has(type) ? type : undefined;
714
+ }
715
+
716
+ /**
717
+ * The upstream Ruby version an OS package carries: 1:3.1.2-7+deb12u1 -> 3.1.2,
718
+ * 3.3.10-r0 -> 3.3.10, 3.0.7-168.el9_8 -> 3.0.7, 3.4.8_1 -> 3.4.8, and the
719
+ * Chocolatey 3.4.10.1 -> 3.4.10.
720
+ *
721
+ * @param {string} version package version
722
+ * @param {string} [purlType] purl type of the package
723
+ * @returns {string|undefined}
724
+ */
725
+ export function upstreamRubyVersion(version, purlType) {
726
+ if (typeof version !== "string" || !version) {
727
+ return undefined;
728
+ }
729
+ let text = version;
730
+ // Chocolatey appends a package fix number: 3.4.10.1 packages Ruby 3.4.10.
731
+ if (purlType === "chocolatey") {
732
+ const match = /^(\d+\.\d+\.\d+)(\.\d+)?$/.exec(text);
733
+ return match ? match[1] : undefined;
734
+ }
735
+ const colon = text.indexOf(":");
736
+ if (colon !== -1 && /^\d+$/.test(text.slice(0, colon))) {
737
+ text = text.slice(colon + 1);
738
+ }
739
+ // Debian sorts a pre-release before its release with a tilde:
740
+ // 2.7.0~preview1-1 packages Ruby 2.7.0-preview1.
741
+ const prerelease = /^(\d+\.\d+\.\d+)~(preview|rc)(\d+)(?=$|[-_+])/.exec(text);
742
+ if (prerelease) {
743
+ return `${prerelease[1]}-${prerelease[2]}${prerelease[3]}`;
744
+ }
745
+ const match = /^(\d+\.\d+\.\d+)(?=$|[-_~+])/.exec(text);
746
+ return match ? match[1] : undefined;
747
+ }
748
+
749
+ /**
750
+ * Describe the Ruby an OS package installed with its upstream identity,
751
+ * `pkg:generic/ruby@<upstream>`, so a BOM names the interpreter both ways. The
752
+ * component of a distro package carries no CPE, since distros backport fixes
753
+ * under their old upstream version; Homebrew and Chocolatey ship the upstream
754
+ * release, so theirs does. `cdx:os:installedBy` names the package to judge it
755
+ * by.
756
+ *
757
+ * @param {Object[]} components BOM components, OS packages among them
758
+ * @returns {Object[]} runtime components to add, one per distinct version
759
+ */
760
+ export function deriveOsPackagedRubyRuntimes(components) {
761
+ if (!Array.isArray(components)) {
762
+ return [];
763
+ }
764
+ const osPackages = components.filter((component) => osPurlType(component));
765
+ const pick = (patterns) =>
766
+ osPackages.filter(
767
+ (component) =>
768
+ osPurlType(component) !== "swid" &&
769
+ patterns.some((pattern) => pattern.test(`${component.name || ""}`)),
770
+ );
771
+ let owners = pick(LIBRARY_PACKAGES);
772
+ if (!owners.length) {
773
+ owners = pick(INTERPRETER_PACKAGES);
774
+ }
775
+ owners = owners.concat(
776
+ osPackages.filter(
777
+ (component) =>
778
+ osPurlType(component) === "swid" &&
779
+ RUBYINSTALLER_PROGRAM.test(`${component.name || ""}`),
780
+ ),
781
+ );
782
+ const existing = new Set(
783
+ components.map((component) => component?.["bom-ref"]),
784
+ );
785
+ const byRef = new Map();
786
+ for (const owner of owners) {
787
+ const type = osPurlType(owner);
788
+ const version =
789
+ type === "swid"
790
+ ? RUBYINSTALLER_PROGRAM.exec(owner.name)[1]
791
+ : upstreamRubyVersion(owner.version, type);
792
+ if (!version) {
793
+ continue;
794
+ }
795
+ const runtime = createRubyRuntimeComponent(version, {
796
+ withCpe: UPSTREAM_BUILD_PURL_TYPES.has(type),
797
+ });
798
+ const ref = runtime["bom-ref"];
799
+ if (!ref || existing.has(ref)) {
800
+ continue;
801
+ }
802
+ const ownerRef = decodeURIComponent(owner.purl);
803
+ const known = byRef.get(ref);
804
+ if (known) {
805
+ if (
806
+ !known.properties.some(
807
+ (prop) =>
808
+ prop.name === "cdx:os:installedBy" && prop.value === ownerRef,
809
+ )
810
+ ) {
811
+ known.properties.push({ name: "cdx:os:installedBy", value: ownerRef });
812
+ }
813
+ continue;
814
+ }
815
+ runtime.properties = [
816
+ { name: "cdx:os:installedBy", value: ownerRef },
817
+ { name: "cdx:ruby:buildVersionSource", value: "os-package" },
818
+ ];
819
+ byRef.set(ref, runtime);
820
+ }
821
+ return [...byRef.values()];
822
+ }
823
+
824
+ /**
825
+ * Where the default gems of an install live: the gemspec paths
826
+ * <prefix>/lib/ruby/gems/<abi>/specifications/default/*.gemspec.
827
+ *
828
+ * @param {string} gemspecPath path of a gemspec, either separator
829
+ * @returns {{prefix: string, abi: string}|undefined} the install prefix and
830
+ * ABI, when the gemspec is a default gem of a lib/ruby/gems tree
831
+ */
832
+ export function defaultGemInstall(gemspecPath) {
833
+ if (typeof gemspecPath !== "string") {
834
+ return undefined;
835
+ }
836
+ const parts = gemspecPath.replaceAll("\\", "/").split("/");
837
+ const n = parts.length;
838
+ if (
839
+ n < 7 ||
840
+ parts[n - 2] !== "default" ||
841
+ parts[n - 3] !== "specifications" ||
842
+ !ABI_RE.test(parts[n - 4]) ||
843
+ parts[n - 5] !== "gems" ||
844
+ parts[n - 6] !== "ruby" ||
845
+ parts[n - 7] !== "lib"
846
+ ) {
847
+ return undefined;
848
+ }
849
+ return { prefix: parts.slice(0, n - 7).join("/") || "/", abi: parts[n - 4] };
850
+ }
851
+
852
+ /**
853
+ * Whether a path lies inside an installed gem's own directory,
854
+ * <gem home>/gems/<name>-<version>/..., beside the specifications directory of
855
+ * that gem home. The lockfiles, manifests and nested gemspecs there belong to
856
+ * the gem's own development, not to anything installed.
857
+ *
858
+ * Only the part of the path from the scanned directory down counts, so a scan
859
+ * of one installed gem (`cdxgen /usr/local/bundle/gems/rails-7.1.0`) still
860
+ * reads that gem's files. The scanned directory may itself be the `gems`
861
+ * directory, as when an image scan reads each gem home on its own.
862
+ *
863
+ * @param {string} filePath absolute path, either separator
864
+ * @param {function(string): boolean} exists existence check
865
+ * @param {string} [scanRoot] the directory the scan started from
866
+ * @returns {boolean}
867
+ */
868
+ export function isInsideInstalledGem(filePath, exists, scanRoot) {
869
+ if (typeof filePath !== "string" || !filePath) {
870
+ return false;
871
+ }
872
+ const parts = filePath.replaceAll("\\", "/").split("/");
873
+ let lowest = 1;
874
+ if (typeof scanRoot === "string" && scanRoot) {
875
+ const rootParts = scanRoot
876
+ .replaceAll("\\", "/")
877
+ .replace(/\/+$/, "")
878
+ .split("/");
879
+ const isUnderRoot = rootParts.every((part, index) => parts[index] === part);
880
+ if (isUnderRoot) {
881
+ lowest = Math.max(1, rootParts.length - 1);
882
+ }
883
+ }
884
+ // The file itself cannot be the gem directory, so stop two short.
885
+ for (let i = parts.length - 3; i >= lowest; i--) {
886
+ if (parts[i] !== "gems" || !/-\d/.test(parts[i + 1] || "")) {
887
+ continue;
888
+ }
889
+ const gemHome = parts.slice(0, i).join("/") || "/";
890
+ if (exists(join(gemHome, "specifications"))) {
891
+ return true;
892
+ }
893
+ }
894
+ return false;
895
+ }
896
+
897
+ /**
898
+ * Link the default gems of an unpackaged Ruby to the runtime they ship with.
899
+ * The runtime is known from its `file` components, which carry the build
900
+ * version and the path they were found at; a default gem belongs to the
901
+ * runtime installed under the same prefix. Gems an OS package installed are
902
+ * left to that package, as is any gem whose install has no unpackaged runtime.
903
+ *
904
+ * @param {Object[]} components BOM components
905
+ * @param {string} [rootDir] extracted root the source paths may start with
906
+ * @returns {{ref: string, dependsOn: string[]}[]} edges from each runtime to
907
+ * its default gems
908
+ */
909
+ export function linkDefaultGemsToRuntimes(components, rootDir) {
910
+ if (!Array.isArray(components)) {
911
+ return [];
912
+ }
913
+ const root = rootDir
914
+ ? `${rootDir}`.replaceAll("\\", "/").replace(/\/+$/, "")
915
+ : undefined;
916
+ // Paths read from an extracted image start with the extraction directory.
917
+ const inRoot = (value) => {
918
+ if (typeof value !== "string") {
919
+ return value;
920
+ }
921
+ const normalized = value.replaceAll("\\", "/");
922
+ return root && normalized.startsWith(`${root}/`)
923
+ ? normalized.slice(root.length)
924
+ : normalized;
925
+ };
926
+ const property = (component, name) => {
927
+ const value = component?.properties?.find(
928
+ (prop) => prop.name === name,
929
+ )?.value;
930
+ return name === "internal:SrcFile" ? inRoot(value) : value;
931
+ };
932
+ const runtimeByPrefix = new Map();
933
+ const refs = new Set(components.map((component) => component?.["bom-ref"]));
934
+ for (const component of components) {
935
+ const version = property(component, "cdx:ruby:buildVersion");
936
+ const srcFile = property(component, "internal:SrcFile");
937
+ if (component?.type !== "file" || !version || !srcFile) {
938
+ continue;
939
+ }
940
+ const prefix = rubyInstallPrefix(srcFile.replaceAll("\\", "/"));
941
+ const runtimeRef = rubyRuntimeRef(version);
942
+ if (prefix && runtimeRef && refs.has(runtimeRef)) {
943
+ runtimeByPrefix.set(prefix, runtimeRef);
944
+ }
945
+ }
946
+ if (!runtimeByPrefix.size) {
947
+ return [];
948
+ }
949
+ const dependsOn = new Map();
950
+ for (const component of components) {
951
+ if (
952
+ !component?.["bom-ref"] ||
953
+ property(component, "cdx:gem:defaultGem") !== "true" ||
954
+ property(component, "cdx:os:installedBy")
955
+ ) {
956
+ continue;
957
+ }
958
+ const install = defaultGemInstall(property(component, "internal:SrcFile"));
959
+ const runtimeRef = install && runtimeByPrefix.get(install.prefix);
960
+ if (!runtimeRef) {
961
+ continue;
962
+ }
963
+ if (!dependsOn.has(runtimeRef)) {
964
+ dependsOn.set(runtimeRef, new Set());
965
+ }
966
+ dependsOn.get(runtimeRef).add(component["bom-ref"]);
967
+ }
968
+ return [...dependsOn.entries()].map(([ref, gems]) => ({
969
+ ref,
970
+ dependsOn: [...gems].sort(),
971
+ }));
972
+ }