@cdxgen/cdxgen 13.3.0 → 13.5.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 (372) hide show
  1. package/README.md +12 -9
  2. package/bin/cdxgen-secure.js +15 -0
  3. package/bin/cdxgen.js +115 -31
  4. package/bin/evinse.js +23 -1
  5. package/bin/hbom.js +15 -7
  6. package/bin/repl.js +115 -0
  7. package/bin/sign.js +45 -15
  8. package/bin/tracebom.js +5 -1
  9. package/bin/validate.js +84 -18
  10. package/bin/verify.js +155 -86
  11. package/data/cbomosdb-queries.json +13 -1
  12. package/data/component-tags.json +8 -0
  13. package/data/helpers/init.gradle +46 -21
  14. package/data/queries-darwin.json +25 -1
  15. package/data/queries-win.json +19 -1
  16. package/data/queries.json +41 -0
  17. package/data/remediations.json +344 -1
  18. package/data/rules/cbom-security.yaml +235 -4
  19. package/data/rules/dependency-sources.yaml +67 -0
  20. package/data/rules/go-binaries.yaml +46 -0
  21. package/data/rules/golem-go.yaml +80 -0
  22. package/data/rules/hbom-compliance.yaml +40 -22
  23. package/data/rules/hbom-performance.yaml +7 -1
  24. package/data/rules/host-topology.yaml +1 -1
  25. package/data/rules/obom-runtime.yaml +31 -2
  26. package/data/rules/php-runtime.yaml +143 -0
  27. package/data/rules/scala-evidence.yaml +115 -0
  28. package/lib/audit/index.js +52 -6
  29. package/lib/cli/bomAssembly.js +28 -3
  30. package/lib/cli/cliOptions.js +110 -3
  31. package/lib/cli/index.js +186 -58
  32. package/lib/cli/jsBom.js +50 -29
  33. package/lib/cli/jvmBom.js +779 -146
  34. package/lib/cli/managedBom.js +1021 -261
  35. package/lib/cli/nativeBom.js +364 -58
  36. package/lib/core/activity.js +12 -0
  37. package/lib/core/env.js +47 -17
  38. package/lib/core/fs.js +31 -21
  39. package/lib/core/httpClient.js +233 -69
  40. package/lib/core/pluginRun.js +184 -0
  41. package/lib/core/processTree.js +100 -0
  42. package/lib/core/projectConfig.js +1 -0
  43. package/lib/core/projectTooling.js +97 -0
  44. package/lib/core/propertySanitizer.js +72 -0
  45. package/lib/core/runState.js +39 -0
  46. package/lib/core/toml.js +72 -0
  47. package/lib/ecosystems/asarutils.js +4 -2
  48. package/lib/ecosystems/bunutils.js +5 -7
  49. package/lib/ecosystems/cargoCache.js +231 -8
  50. package/lib/ecosystems/cmakeResolver.js +10 -21
  51. package/lib/ecosystems/cppBuildContext.js +631 -0
  52. package/lib/ecosystems/cppEvidence.js +337 -22
  53. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  54. package/lib/ecosystems/denoutils.js +93 -66
  55. package/lib/ecosystems/ecosystems.js +1967 -788
  56. package/lib/ecosystems/golem.js +578 -18
  57. package/lib/ecosystems/gradleutils.js +340 -46
  58. package/lib/ecosystems/jsEvidence.js +85 -24
  59. package/lib/ecosystems/kosi.js +21 -4
  60. package/lib/ecosystems/npmutils.js +64 -0
  61. package/lib/ecosystems/parsers-dotnet.js +668 -153
  62. package/lib/ecosystems/parsers-elm.js +46 -28
  63. package/lib/ecosystems/parsers-gleam.js +3 -4
  64. package/lib/ecosystems/parsers-go.js +1283 -297
  65. package/lib/ecosystems/parsers-js.js +92 -26
  66. package/lib/ecosystems/parsers-julia.js +2 -3
  67. package/lib/ecosystems/parsers-jvm.js +463 -29
  68. package/lib/ecosystems/parsers-misc.js +405 -93
  69. package/lib/ecosystems/parsers-python.js +152 -20
  70. package/lib/ecosystems/parsers-rust.js +4 -4
  71. package/lib/ecosystems/parsers-terraform.js +1573 -19
  72. package/lib/ecosystems/pythonutils.js +102 -6
  73. package/lib/ecosystems/remote/huggingface.js +3 -0
  74. package/lib/ecosystems/rubyutils.js +73 -20
  75. package/lib/ecosystems/rusi.js +32 -9
  76. package/lib/ecosystems/sbtutils.js +243 -187
  77. package/lib/ecosystems/scalacliutils.js +343 -0
  78. package/lib/ecosystems/scalasem.js +2620 -0
  79. package/lib/ecosystems/terraformRegistry.js +288 -0
  80. package/lib/ecosystems/utils.js +2 -0
  81. package/lib/ecosystems/vendoredCode.js +156 -0
  82. package/lib/evinser/evinser.js +695 -78
  83. package/lib/evinser/swiftsem.js +68 -5
  84. package/lib/helpers/agenticToolutils.js +2 -2
  85. package/lib/helpers/bomSigner.js +576 -128
  86. package/lib/inventory/agentSkillSpec.js +17 -0
  87. package/lib/inventory/aiCollector.js +4 -1
  88. package/lib/inventory/analyzer.js +110 -127
  89. package/lib/inventory/apkInstalledDb.js +243 -0
  90. package/lib/inventory/atomUtils.js +204 -48
  91. package/lib/inventory/auditCategories.js +10 -3
  92. package/lib/inventory/buildToolRateLimit.js +144 -0
  93. package/lib/inventory/cacheCommands.js +2 -1
  94. package/lib/inventory/cacheDir.js +52 -9
  95. package/lib/inventory/certificates.js +2371 -0
  96. package/lib/inventory/cmakeBuildDirs.js +254 -0
  97. package/lib/inventory/communityAiConfigParser.js +4 -9
  98. package/lib/inventory/deps.js +507 -128
  99. package/lib/inventory/display.js +200 -3
  100. package/lib/inventory/dosai.js +747 -101
  101. package/lib/inventory/dosaiParsers.js +310 -15
  102. package/lib/inventory/dynamic.js +96 -8
  103. package/lib/inventory/envcontext.js +168 -5
  104. package/lib/inventory/evidenceUtils.js +590 -132
  105. package/lib/inventory/fetchBatch.js +574 -38
  106. package/lib/inventory/fetchDiskCache.js +597 -0
  107. package/lib/inventory/forgeEnricher.js +14 -5
  108. package/lib/inventory/formulationParsers.js +3 -2
  109. package/lib/inventory/goBuildInfo.js +140 -0
  110. package/lib/inventory/hbom.js +67 -7
  111. package/lib/inventory/hbomAnalysis.js +16 -0
  112. package/lib/inventory/hostTopology.js +21 -0
  113. package/lib/inventory/jvmLocalRepos.js +917 -0
  114. package/lib/inventory/mcpConfigParser.js +2 -3
  115. package/lib/inventory/osPackagePurl.js +244 -0
  116. package/lib/inventory/osPackageResolver.js +89 -87
  117. package/lib/inventory/osinfo.js +79 -31
  118. package/lib/inventory/osqueryTransform.js +114 -5
  119. package/lib/inventory/parentSelection.js +146 -0
  120. package/lib/inventory/phpBuildInfo.js +487 -0
  121. package/lib/inventory/phpHostProbe.js +641 -0
  122. package/lib/inventory/rootfsCerts.js +426 -0
  123. package/lib/inventory/rustFormulationParser.js +3 -4
  124. package/lib/inventory/scalaCoords.js +153 -0
  125. package/lib/inventory/source.js +215 -11
  126. package/lib/inventory/spdx.js +245 -0
  127. package/lib/inventory/traceRunner.js +93 -8
  128. package/lib/inventory/tracedCrypto.js +423 -0
  129. package/lib/managers/binary.js +529 -183
  130. package/lib/managers/docker.js +199 -44
  131. package/lib/managers/oci.js +24 -2
  132. package/lib/parsers/cmakeCache.js +69 -0
  133. package/lib/parsers/cmakeLists.js +16 -2
  134. package/lib/parsers/cmakePresets.js +400 -0
  135. package/lib/parsers/compileCommands.js +557 -0
  136. package/lib/parsers/email.js +277 -0
  137. package/lib/parsers/largeJson.js +34 -2
  138. package/lib/parsers/strictJson.js +322 -0
  139. package/lib/parsers/terraformConfig.js +1073 -0
  140. package/lib/parsers/terraformSource.js +652 -0
  141. package/lib/server/server.js +209 -1
  142. package/lib/stages/postgen/annotator.js +17 -1
  143. package/lib/stages/postgen/introspection/markers.js +1 -1
  144. package/lib/stages/postgen/introspection/reflect.js +4 -1
  145. package/lib/stages/postgen/postgen.js +20 -6
  146. package/lib/stages/postgen/ruleEngine.js +58 -1
  147. package/lib/stages/postgen/specVersionCompat.js +62 -0
  148. package/lib/stages/pregen/pregen.js +117 -5
  149. package/lib/validator/bomValidator.js +26 -5
  150. package/lib/validator/generated/runtime.mjs +13 -0
  151. package/lib/validator/generated/validate-1.6.mjs +26 -7
  152. package/lib/validator/generated/validate-1.7.mjs +31 -12
  153. package/lib/validator/generated/validate-2.0.mjs +30 -11
  154. package/lib/validator/index.js +25 -10
  155. package/lib/validator/reporters/json.js +1 -0
  156. package/package.json +23 -23
  157. package/types/bin/cdxgen-secure.d.ts +3 -0
  158. package/types/bin/cdxgen-secure.d.ts.map +1 -0
  159. package/types/bin/repl.d.ts.map +1 -1
  160. package/types/lib/audit/index.d.ts.map +1 -1
  161. package/types/lib/cli/bomAssembly.d.ts +9 -2
  162. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  163. package/types/lib/cli/cliOptions.d.ts +43 -0
  164. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  165. package/types/lib/cli/index.d.ts.map +1 -1
  166. package/types/lib/cli/jsBom.d.ts.map +1 -1
  167. package/types/lib/cli/jvmBom.d.ts +44 -1
  168. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  169. package/types/lib/cli/managedBom.d.ts +7 -4
  170. package/types/lib/cli/managedBom.d.ts.map +1 -1
  171. package/types/lib/cli/nativeBom.d.ts +3 -1
  172. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  173. package/types/lib/core/activity.d.ts.map +1 -1
  174. package/types/lib/core/env.d.ts +27 -2
  175. package/types/lib/core/env.d.ts.map +1 -1
  176. package/types/lib/core/fs.d.ts.map +1 -1
  177. package/types/lib/core/httpClient.d.ts +13 -8
  178. package/types/lib/core/httpClient.d.ts.map +1 -1
  179. package/types/lib/core/pluginRun.d.ts +97 -0
  180. package/types/lib/core/pluginRun.d.ts.map +1 -0
  181. package/types/lib/core/processTree.d.ts +36 -0
  182. package/types/lib/core/processTree.d.ts.map +1 -0
  183. package/types/lib/core/projectConfig.d.ts.map +1 -1
  184. package/types/lib/core/projectTooling.d.ts +41 -0
  185. package/types/lib/core/projectTooling.d.ts.map +1 -0
  186. package/types/lib/core/propertySanitizer.d.ts +26 -0
  187. package/types/lib/core/propertySanitizer.d.ts.map +1 -1
  188. package/types/lib/core/runState.d.ts +29 -0
  189. package/types/lib/core/runState.d.ts.map +1 -0
  190. package/types/lib/core/toml.d.ts +20 -0
  191. package/types/lib/core/toml.d.ts.map +1 -0
  192. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  193. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  194. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  195. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  196. package/types/lib/ecosystems/cmakeResolver.d.ts +5 -3
  197. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  198. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  199. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  200. package/types/lib/ecosystems/cppEvidence.d.ts +19 -3
  201. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  202. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  203. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  204. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  205. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  206. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  207. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  208. package/types/lib/ecosystems/golem.d.ts +81 -1
  209. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  210. package/types/lib/ecosystems/gradleutils.d.ts +59 -7
  211. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  212. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  213. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  214. package/types/lib/ecosystems/npmutils.d.ts +15 -0
  215. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  216. package/types/lib/ecosystems/parsers-dotnet.d.ts +155 -5
  217. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  218. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  219. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  220. package/types/lib/ecosystems/parsers-go.d.ts +119 -5
  221. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  222. package/types/lib/ecosystems/parsers-js.d.ts +6 -1
  223. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  224. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  225. package/types/lib/ecosystems/parsers-jvm.d.ts +74 -3
  226. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  227. package/types/lib/ecosystems/parsers-misc.d.ts +21 -59
  228. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  229. package/types/lib/ecosystems/parsers-python.d.ts +57 -6
  230. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  231. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  232. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  233. package/types/lib/ecosystems/pythonutils.d.ts +26 -1
  234. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  235. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  236. package/types/lib/ecosystems/rubyutils.d.ts +21 -1
  237. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  238. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  239. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  240. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  241. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  242. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  243. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  244. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  245. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  246. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  247. package/types/lib/ecosystems/utils.d.ts +2 -2
  248. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  249. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  250. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  251. package/types/lib/evinser/evinser.d.ts +100 -8
  252. package/types/lib/evinser/evinser.d.ts.map +1 -1
  253. package/types/lib/evinser/swiftsem.d.ts +10 -0
  254. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  255. package/types/lib/helpers/bomSigner.d.ts +80 -4
  256. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  257. package/types/lib/inventory/agentSkillSpec.d.ts +13 -0
  258. package/types/lib/inventory/agentSkillSpec.d.ts.map +1 -0
  259. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  260. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  261. package/types/lib/inventory/apkInstalledDb.d.ts +42 -0
  262. package/types/lib/inventory/apkInstalledDb.d.ts.map +1 -0
  263. package/types/lib/inventory/atomUtils.d.ts +96 -10
  264. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  265. package/types/lib/inventory/auditCategories.d.ts +3 -0
  266. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  267. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  268. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  269. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  270. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  271. package/types/lib/inventory/cacheDir.d.ts +12 -4
  272. package/types/lib/inventory/cacheDir.d.ts.map +1 -1
  273. package/types/lib/inventory/certificates.d.ts +205 -0
  274. package/types/lib/inventory/certificates.d.ts.map +1 -0
  275. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  276. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  277. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  278. package/types/lib/inventory/deps.d.ts +61 -2
  279. package/types/lib/inventory/deps.d.ts.map +1 -1
  280. package/types/lib/inventory/display.d.ts +18 -0
  281. package/types/lib/inventory/display.d.ts.map +1 -1
  282. package/types/lib/inventory/dosai.d.ts +202 -14
  283. package/types/lib/inventory/dosai.d.ts.map +1 -1
  284. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  285. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  286. package/types/lib/inventory/dynamic.d.ts +16 -1
  287. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  288. package/types/lib/inventory/envcontext.d.ts +40 -2
  289. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  290. package/types/lib/inventory/evidenceUtils.d.ts +48 -14
  291. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  292. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  293. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  294. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  295. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  296. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  297. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  298. package/types/lib/inventory/goBuildInfo.d.ts +10 -0
  299. package/types/lib/inventory/goBuildInfo.d.ts.map +1 -0
  300. package/types/lib/inventory/hbom.d.ts.map +1 -1
  301. package/types/lib/inventory/hbomAnalysis.d.ts +10 -0
  302. package/types/lib/inventory/hbomAnalysis.d.ts.map +1 -1
  303. package/types/lib/inventory/hostTopology.d.ts.map +1 -1
  304. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  305. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  306. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  307. package/types/lib/inventory/osPackagePurl.d.ts +99 -0
  308. package/types/lib/inventory/osPackagePurl.d.ts.map +1 -0
  309. package/types/lib/inventory/osPackageResolver.d.ts +6 -2
  310. package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
  311. package/types/lib/inventory/osinfo.d.ts +31 -6
  312. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  313. package/types/lib/inventory/osqueryTransform.d.ts +51 -0
  314. package/types/lib/inventory/osqueryTransform.d.ts.map +1 -1
  315. package/types/lib/inventory/parentSelection.d.ts +34 -0
  316. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  317. package/types/lib/inventory/phpBuildInfo.d.ts +142 -0
  318. package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -0
  319. package/types/lib/inventory/phpHostProbe.d.ts +35 -0
  320. package/types/lib/inventory/phpHostProbe.d.ts.map +1 -0
  321. package/types/lib/inventory/rootfsCerts.d.ts +27 -0
  322. package/types/lib/inventory/rootfsCerts.d.ts.map +1 -0
  323. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  324. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  325. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  326. package/types/lib/inventory/source.d.ts +28 -0
  327. package/types/lib/inventory/source.d.ts.map +1 -1
  328. package/types/lib/inventory/spdx.d.ts +24 -0
  329. package/types/lib/inventory/spdx.d.ts.map +1 -1
  330. package/types/lib/inventory/traceRunner.d.ts +16 -1
  331. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  332. package/types/lib/inventory/tracedCrypto.d.ts +64 -0
  333. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  334. package/types/lib/managers/binary.d.ts +10 -0
  335. package/types/lib/managers/binary.d.ts.map +1 -1
  336. package/types/lib/managers/docker.d.ts +12 -0
  337. package/types/lib/managers/docker.d.ts.map +1 -1
  338. package/types/lib/managers/oci.d.ts +3 -1
  339. package/types/lib/managers/oci.d.ts.map +1 -1
  340. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  341. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  342. package/types/lib/parsers/cmakeLists.d.ts +2 -1
  343. package/types/lib/parsers/cmakeLists.d.ts.map +1 -1
  344. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  345. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  346. package/types/lib/parsers/compileCommands.d.ts +115 -0
  347. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  348. package/types/lib/parsers/email.d.ts +19 -0
  349. package/types/lib/parsers/email.d.ts.map +1 -0
  350. package/types/lib/parsers/largeJson.d.ts +13 -0
  351. package/types/lib/parsers/largeJson.d.ts.map +1 -1
  352. package/types/lib/parsers/strictJson.d.ts +46 -0
  353. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  354. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  355. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  356. package/types/lib/parsers/terraformSource.d.ts +66 -0
  357. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  358. package/types/lib/server/server.d.ts +8 -0
  359. package/types/lib/server/server.d.ts.map +1 -1
  360. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  361. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -1
  362. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  363. package/types/lib/stages/postgen/ruleEngine.d.ts +7 -4
  364. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  365. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  366. package/types/lib/stages/pregen/pregen.d.ts +14 -1
  367. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  368. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  369. package/types/lib/validator/index.d.ts +8 -0
  370. package/types/lib/validator/index.d.ts.map +1 -1
  371. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  372. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
@@ -1,32 +1,134 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
2
3
 
3
- import { tryBuildPurl } from "../inventory/purl.js";
4
+ import { DEBUG_MODE } from "../core/activity.js";
5
+ import { getAllFiles, safeExistsSync } from "../core/fs.js";
6
+ import { thoughtLog } from "../core/logger.js";
7
+ import { applyPurl, tryBuildPurl } from "../inventory/purl.js";
8
+ import { isSafeGitRefName } from "../inventory/source.js";
9
+ import { licenseIdFromText } from "../inventory/spdx.js";
10
+ import { submodulePurlCoordinates } from "../parsers/gitmodules.js";
11
+ import {
12
+ mergeTerraformConfigs,
13
+ parseTerraformConfig,
14
+ terraformConfigFileSet,
15
+ } from "../parsers/terraformConfig.js";
16
+ import {
17
+ classifyVcsRef,
18
+ classifyVersionConstraint,
19
+ OPENTOFU_REGISTRY_HOST,
20
+ parseModuleSource,
21
+ parseProviderSource,
22
+ TERRAFORM_REGISTRY_HOST,
23
+ } from "../parsers/terraformSource.js";
4
24
 
5
25
  /**
6
- * Terraform and OpenTofu parser for `.terraform.lock.hcl`.
26
+ * Terraform and OpenTofu parsing: the provider lock file, the module
27
+ * manifest `init` writes, and the configuration files that declare modules
28
+ * and required providers.
7
29
  *
8
- * The lock file pins every provider the configuration resolves to, with its
9
- * exact version, the version constraints that produced it, and content
10
- * hashes. It is written in a small, regular subset of HCL: provider blocks,
11
- * `version` and `constraints` attributes, and a `hashes` list of prefixed
12
- * digests. A line scanner is sufficient and avoids a general HCL parser.
30
+ * The lock file is written in a small, regular subset of HCL: provider
31
+ * blocks, `version` and `constraints` attributes, and a `hashes` list of
32
+ * prefixed digests. A line scanner is sufficient and avoids a general HCL
33
+ * parser (see {@link parseTerraformLockFile}); configuration files go
34
+ * through the dedicated extractor in `lib/parsers/terraformConfig.js`.
13
35
  *
14
- * Provider addresses look like `registry.terraform.io/hashicorp/aws`; the
15
- * registry host and namespace identify the provider upstream, so they are
16
- * kept in the component group and a generic purl
36
+ * Modules are not in the lock file — Terraform designed it that way — so the
37
+ * workspace assembly reads `.terraform/modules/modules.json` when `init` has
38
+ * run and falls back to a static walk of `module` blocks. Provider addresses
39
+ * look like `registry.terraform.io/hashicorp/aws`; the registry host and
40
+ * namespace identify the provider upstream, so they are kept in the
41
+ * component group and a generic purl
17
42
  * (`pkg:generic/<host>/<namespace>/<type>@<version>`), with the intended type
18
43
  * recorded as a `cdx:purl:proposedType` property because no `terraform`
19
- * purl type is registered.
44
+ * purl type is registered. Local modules are flattened into their calling
45
+ * package: they are part of the project, not dependencies.
20
46
  *
21
- * The two hash schemes in the `hashes` list mean different things. A `zh:`
22
- * digest is a SHA-256 of the official `.zip` package as the registry indexes
23
- * it, so it is the one a consumer can check an artifact against and the one
24
- * emitted in the CycloneDX `hashes` array. An `h1:` digest is base64 over a
25
- * hash of the package's *contents* rather than the archive, which lets
26
- * Terraform verify an unpacked directory but makes it useless as an artifact
27
- * checksum; it is kept as a property instead of being mislabelled SHA-256.
47
+ * Everything this module reads comes from untrusted trees, so every read is
48
+ * bounds-checked by {@link readFileWithin} (realpath containment, regular
49
+ * files only, byte caps) and module source strings are sanitized upstream in
50
+ * `terraformSource.js`. No network I/O happens here; remote enrichment lives
51
+ * in `terraformRegistry.js`.
28
52
  */
29
53
 
54
+ const CONFIG_MAX_BYTES = 5 * 1024 * 1024;
55
+ const MODULES_JSON_MAX_BYTES = 20 * 1024 * 1024;
56
+ const LICENSE_MAX_BYTES = 256 * 1024;
57
+ const GIT_HEAD_MAX_BYTES = 1024;
58
+ const MAX_MODULE_RECORDS = 10000;
59
+ const MAX_LOCAL_DEPTH = 64;
60
+ const MAX_OCCURRENCES = 25;
61
+ const REMOTE_KINDS = new Set(["registry", "git", "hg", "http", "s3", "gcs"]);
62
+ const LICENSE_CANDIDATES = [
63
+ "LICENSE",
64
+ "LICENSE.md",
65
+ "LICENSE.txt",
66
+ "LICENCE",
67
+ "LICENCE.md",
68
+ "LICENCE.txt",
69
+ "COPYING",
70
+ "COPYING.md",
71
+ "COPYING.txt",
72
+ ];
73
+ /** Longest archive extensions first so `.tar.gz` survives `.tar` checks. */
74
+ const ARCHIVE_EXTENSIONS = [
75
+ ".tar.gz",
76
+ ".tar.bz2",
77
+ ".tar.xz",
78
+ ".tbz2",
79
+ ".tgz",
80
+ ".txz",
81
+ ".zip",
82
+ ".tar",
83
+ ];
84
+ /**
85
+ * Trees Terraform discovery never descends into. `.terraform` and
86
+ * `.terragrunt-cache` are download caches whose contents the workspace reads
87
+ * through exact paths only. `node_modules` holds other packages' files: a
88
+ * `.tf` shipped inside an npm package is not a configuration of the scanned
89
+ * project, and a local module call that points into `node_modules` is still
90
+ * followed. Listing it explicitly matters because another collector in the
91
+ * same multi-type scan may have set `includeNodeModulesDir` on the shared
92
+ * options, which otherwise makes every glob walk it.
93
+ */
94
+ const TERRAFORM_EXCLUDES = [
95
+ "**/.terraform/**",
96
+ "**/.terragrunt-cache/**",
97
+ "**/node_modules/**",
98
+ ];
99
+
100
+ /**
101
+ * The configuration and lock files Terraform discovery considers under a
102
+ * path, with the discovery exclusions applied.
103
+ *
104
+ * @param {string} scanPath Directory to scan
105
+ * @param {Object} [options] CLI options
106
+ * @returns {string[]} Matching files
107
+ */
108
+ function terraformDiscoveryFiles(scanPath, options = {}) {
109
+ const opts = {
110
+ ...options,
111
+ exclude: [...(options.exclude || []), ...TERRAFORM_EXCLUDES],
112
+ };
113
+ const prefix = options.multiProject ? "**/" : "";
114
+ return [
115
+ ...getAllFiles(scanPath, `${prefix}*.{tf,tf.json,tofu,tofu.json}`, opts),
116
+ ...getAllFiles(scanPath, `${prefix}.terraform.lock.hcl`, opts),
117
+ ];
118
+ }
119
+
120
+ /**
121
+ * Whether a path holds any Terraform or OpenTofu configuration or lock file
122
+ * the workspace assembly would consider; used by project-type autodetection.
123
+ *
124
+ * @param {string} scanPath Directory to scan
125
+ * @param {Object} [options] CLI options
126
+ * @returns {boolean}
127
+ */
128
+ export function hasTerraformConfiguration(scanPath, options = {}) {
129
+ return terraformDiscoveryFiles(scanPath, options).length > 0;
130
+ }
131
+
30
132
  /**
31
133
  * Parse a `.terraform.lock.hcl` file.
32
134
  *
@@ -135,6 +237,7 @@ function buildProviderComponent(block, srcFile) {
135
237
  const properties = [
136
238
  { name: "internal:SrcFile", value: srcFile },
137
239
  { name: "cdx:purl:proposedType", value: "terraform-provider" },
240
+ { name: "cdx:tf:kind", value: "provider" },
138
241
  { name: "cdx:tf:address", value: block.address },
139
242
  ];
140
243
  if (block.constraints) {
@@ -192,3 +295,1454 @@ function zipDigestsHex(candidates) {
192
295
  }
193
296
  return [...digests];
194
297
  }
298
+
299
+ /**
300
+ * Read a file only when it stays inside `allowedRoot` (by realpath), is a
301
+ * regular file, and is under `maxBytes`.
302
+ *
303
+ * @param {string} file Absolute path to read
304
+ * @param {string} allowedRoot Absolute directory the file must live under
305
+ * @param {number} maxBytes Byte cap
306
+ * @returns {string|undefined} File contents, or undefined on any failure
307
+ */
308
+ function readFileWithin(file, allowedRoot, maxBytes) {
309
+ try {
310
+ const realFile = realpathSync(file);
311
+ const realRoot = realpathSync(allowedRoot);
312
+ if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
313
+ return undefined;
314
+ }
315
+ const stats = statSync(realFile);
316
+ if (!stats.isFile() || stats.size > maxBytes) {
317
+ return undefined;
318
+ }
319
+ return readFileSync(realFile, "utf-8");
320
+ } catch {
321
+ return undefined;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * List a directory's entry names, or undefined when unreadable.
327
+ *
328
+ * @param {string} dir Directory
329
+ * @returns {string[]|undefined} Entry names
330
+ */
331
+ function listDirNames(dir) {
332
+ try {
333
+ return readdirSync(dir);
334
+ } catch {
335
+ return undefined;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * POSIX-style path of `target` relative to `from`.
341
+ *
342
+ * @param {string} from Base directory
343
+ * @param {string} target Absolute path
344
+ * @returns {string} Relative path with `/` separators
345
+ */
346
+ function posixRelative(from, target) {
347
+ return relative(from, target).split(sep).join("/");
348
+ }
349
+
350
+ /**
351
+ * Whether `child` is `parent` or lives underneath it (lexical check; real
352
+ * paths are enforced separately by {@link readFileWithin}).
353
+ *
354
+ * @param {string} parent Absolute directory
355
+ * @param {string} child Absolute path
356
+ * @returns {boolean}
357
+ */
358
+ function isInsideDir(parent, child) {
359
+ const rel = relative(parent, child);
360
+ if (!rel) {
361
+ return true;
362
+ }
363
+ if (isAbsolute(rel)) {
364
+ return false;
365
+ }
366
+ const first = rel.split(sep)[0];
367
+ return first !== ".." && first !== ".";
368
+ }
369
+
370
+ /** Parent key of a dotted manifest key. */
371
+ function parentKeyOf(key) {
372
+ const dot = key.lastIndexOf(".");
373
+ return dot === -1 ? "" : key.slice(0, dot);
374
+ }
375
+
376
+ /** Safe realpath wrapper. */
377
+ function realpathOf(target) {
378
+ try {
379
+ return realpathSync(target);
380
+ } catch {
381
+ return undefined;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Locate the Terraform roots under `scanPath`: directories that own a
387
+ * configuration — those with a lock file or module manifest, plus config
388
+ * directories no other config directory references through a local module
389
+ * call.
390
+ *
391
+ * @param {string} scanPath Directory to scan
392
+ * @param {Object} options CLI options
393
+ * @returns {{ dir: string, lockFile?: string, manifestFile?: string }[]} Roots sorted by relative POSIX path
394
+ */
395
+ export function findTerraformRoots(scanPath, options = {}) {
396
+ const configFiles = terraformDiscoveryFiles(scanPath, options);
397
+ const scanReal = realpathOf(scanPath);
398
+ if (!scanReal) {
399
+ return [];
400
+ }
401
+ const candidates = new Set();
402
+ for (const file of configFiles) {
403
+ const dir = join(file, "..");
404
+ const dirReal = realpathOf(dir);
405
+ if (!dirReal || !isInsideDir(scanReal, dirReal)) {
406
+ continue;
407
+ }
408
+ const rel = relative(scanPath, dir).split(sep);
409
+ if (
410
+ rel.includes(".terraform") ||
411
+ rel.includes(".terragrunt-cache") ||
412
+ rel.includes("node_modules")
413
+ ) {
414
+ continue;
415
+ }
416
+ candidates.add(dir);
417
+ }
418
+ const candidateList = [...candidates].sort();
419
+ if (!candidateList.length) {
420
+ return [];
421
+ }
422
+ const configs = new Map();
423
+ for (const dir of candidateList) {
424
+ configs.set(dir, loadMergedConfig(dir, scanReal));
425
+ }
426
+ const referenced = new Set();
427
+ for (const dir of candidateList) {
428
+ for (const call of configs.get(dir).moduleCalls) {
429
+ if (!call.source || call.sourceLiteral !== true) {
430
+ continue;
431
+ }
432
+ const parsed = parseModuleSource(call.source);
433
+ if (parsed?.kind !== "local") {
434
+ continue;
435
+ }
436
+ const target = resolve(dir, parsed.path);
437
+ if (target !== dir && candidates.has(target)) {
438
+ referenced.add(target);
439
+ }
440
+ }
441
+ }
442
+ let roots = candidateList.filter(
443
+ (dir) =>
444
+ safeExistsSync(join(dir, ".terraform.lock.hcl")) ||
445
+ safeExistsSync(join(dir, ".terraform", "modules", "modules.json")) ||
446
+ !referenced.has(dir),
447
+ );
448
+ if (!roots.length) {
449
+ roots = candidateList;
450
+ }
451
+ return roots
452
+ .map((dir) => rootDescriptor(dir))
453
+ .sort((a, b) =>
454
+ posixRelative(scanPath, a.dir) < posixRelative(scanPath, b.dir) ? -1 : 1,
455
+ );
456
+ }
457
+
458
+ /**
459
+ * Build the root descriptor with its lock and manifest paths.
460
+ *
461
+ * @param {string} dir Root directory
462
+ * @param {string} scanPath Scan root
463
+ * @returns {{ dir: string, lockFile?: string, manifestFile?: string }}
464
+ */
465
+ function rootDescriptor(dir) {
466
+ const lockFile = join(dir, ".terraform.lock.hcl");
467
+ const manifestFile = join(dir, ".terraform", "modules", "modules.json");
468
+ return {
469
+ dir,
470
+ ...(safeExistsSync(lockFile) ? { lockFile } : {}),
471
+ ...(safeExistsSync(manifestFile) ? { manifestFile } : {}),
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Load and merge one directory's configuration files. Only the exact files
477
+ * Terraform itself would load are read (OpenTofu `.tofu` precedence and
478
+ * override ordering included), each through the bounded reader.
479
+ *
480
+ * @param {string} dir Directory to read
481
+ * @param {string} scanReal Realpath of the scan root (read containment)
482
+ * @returns {object} Merged configuration
483
+ */
484
+ function loadMergedConfig(dir, scanReal) {
485
+ const names = listDirNames(dir) || [];
486
+ const fileSet = terraformConfigFileSet(names);
487
+ const parsedFiles = [];
488
+ for (const base of [...fileSet.primary, ...fileSet.overrides]) {
489
+ const abs = join(dir, base);
490
+ const text = readFileWithin(abs, scanReal, CONFIG_MAX_BYTES);
491
+ if (text === undefined) {
492
+ continue;
493
+ }
494
+ parsedFiles.push({
495
+ file: base,
496
+ override: fileSet.overrides.includes(base),
497
+ result: parseTerraformConfig(text, { json: base.endsWith(".json") }),
498
+ });
499
+ }
500
+ return mergeTerraformConfigs(parsedFiles);
501
+ }
502
+
503
+ /**
504
+ * Read `.terraform/modules/modules.json` for one root.
505
+ *
506
+ * @param {string} manifestFile Absolute manifest path
507
+ * @param {string} rootDir Root directory
508
+ * @returns {undefined|{ records: object[] }} Manifest records, or undefined when absent/invalid
509
+ */
510
+ function readModulesManifest(manifestFile, rootDir) {
511
+ const text = readFileWithin(
512
+ manifestFile,
513
+ join(rootDir, ".terraform"),
514
+ MODULES_JSON_MAX_BYTES,
515
+ );
516
+ if (text === undefined) {
517
+ return undefined;
518
+ }
519
+ let doc;
520
+ try {
521
+ doc = JSON.parse(text);
522
+ } catch {
523
+ return undefined;
524
+ }
525
+ const modules = doc?.Modules;
526
+ if (!Array.isArray(modules) || modules.length > MAX_MODULE_RECORDS) {
527
+ return undefined;
528
+ }
529
+ const records = [];
530
+ for (const record of modules) {
531
+ if (!record || typeof record !== "object") {
532
+ continue;
533
+ }
534
+ if (
535
+ typeof record.Key !== "string" ||
536
+ typeof record.Source !== "string" ||
537
+ typeof record.Dir !== "string"
538
+ ) {
539
+ continue;
540
+ }
541
+ records.push({
542
+ key: record.Key,
543
+ source: record.Source,
544
+ ...(typeof record.Version === "string"
545
+ ? { version: record.Version }
546
+ : {}),
547
+ dirRaw: record.Dir,
548
+ });
549
+ }
550
+ return { records };
551
+ }
552
+
553
+ /**
554
+ * Inventory a Terraform workspace: providers from the lock file and
555
+ * `required_providers`, modules from the manifest or the configuration, the
556
+ * dependency graph between them, and offline licenses from the installed
557
+ * packages.
558
+ *
559
+ * @param {string} scanPath Directory to scan
560
+ * @param {Object} options CLI options
561
+ * @param {string} parentRef `bom-ref` of the parent component the CLI built
562
+ * @returns {{ pkgList: object[], dependencies: object[], parentProperties: object[], srcFiles: string[] }}
563
+ */
564
+ export function parseTerraformWorkspace(scanPath, options, parentRef) {
565
+ const roots = parentRef ? findTerraformRoots(scanPath, options) : [];
566
+ const state = {
567
+ byRef: new Map(),
568
+ edges: new Map(),
569
+ parentProperties: [],
570
+ srcFiles: [],
571
+ parentRef,
572
+ scanPath,
573
+ scanReal: realpathOf(scanPath),
574
+ // Lexical root for path *resolution* (module sources resolve against the
575
+ // path the caller gave, which may itself sit behind a symlink; the real
576
+ // path is enforced where files are actually read).
577
+ scanLexRoot: resolve(scanPath),
578
+ };
579
+ if (!roots.length) {
580
+ return {
581
+ pkgList: [],
582
+ dependencies: [],
583
+ parentProperties: [],
584
+ srcFiles: [],
585
+ };
586
+ }
587
+ if (DEBUG_MODE) {
588
+ console.log(
589
+ `Found ${roots.length} terraform root(s) at ${scanPath}: ${roots
590
+ .map((root) => posixRelative(scanPath, root.dir))
591
+ .join(", ")}`,
592
+ );
593
+ }
594
+ state.singleRoot = roots.length === 1;
595
+ for (const root of roots) {
596
+ processRoot(state, root);
597
+ }
598
+ const pkgList = [...state.byRef.values()].sort((a, b) =>
599
+ a["bom-ref"] < b["bom-ref"] ? -1 : 1,
600
+ );
601
+ const allRefs = new Set([...state.byRef.keys(), parentRef]);
602
+ const dependencies = [...allRefs].sort().map((ref) => ({
603
+ ref,
604
+ dependsOn: [...(state.edges.get(ref) || new Set())]
605
+ .filter((dep) => dep !== ref && allRefs.has(dep))
606
+ .sort(),
607
+ }));
608
+ return {
609
+ pkgList,
610
+ dependencies,
611
+ parentProperties: state.parentProperties,
612
+ srcFiles: state.srcFiles,
613
+ };
614
+ }
615
+
616
+ /**
617
+ * Process one root directory into components and edges.
618
+ *
619
+ * @param {object} state Accumulator shared across roots
620
+ * @param {{ dir: string, lockFile?: string, manifestFile?: string }} root Root descriptor
621
+ */
622
+ function processRoot(state, root) {
623
+ const { scanPath, scanReal, parentRef } = state;
624
+ const rootDir = root.dir;
625
+ const rootRel = posixRelative(scanPath, rootDir) || ".";
626
+ const configCache = new Map();
627
+ const configOf = (dir) => {
628
+ if (!configCache.has(dir)) {
629
+ configCache.set(dir, loadMergedConfig(dir, scanReal));
630
+ }
631
+ return configCache.get(dir);
632
+ };
633
+ const parsedDirs = new Map();
634
+ const parseConfigInto = (dir) => {
635
+ if (!parsedDirs.has(dir)) {
636
+ parsedDirs.set(dir, configOf(dir));
637
+ }
638
+ return parsedDirs.get(dir);
639
+ };
640
+
641
+ const rootConfig = parseConfigInto(rootDir);
642
+
643
+ // Providers from the lock file, indexed by full address and by
644
+ // namespace/type (the host is not always spelled out in the config).
645
+ const lockProviders = [];
646
+ if (root.lockFile) {
647
+ for (const pkg of parseTerraformLockFile(root.lockFile).pkgList) {
648
+ pkg.evidence = {
649
+ identity: {
650
+ field: "purl",
651
+ confidence: 1,
652
+ methods: [
653
+ {
654
+ technique: "manifest-analysis",
655
+ confidence: 1,
656
+ value: posixRelative(scanPath, root.lockFile),
657
+ },
658
+ ],
659
+ },
660
+ };
661
+ lockProviders.push(pkg);
662
+ }
663
+ }
664
+ const lockByAddress = new Map(
665
+ lockProviders
666
+ .map((pkg) => [findProperty(pkg, "cdx:tf:address"), pkg])
667
+ .filter(([address]) => address),
668
+ );
669
+ const lockByNsType = new Map();
670
+ for (const [address, pkg] of lockByAddress) {
671
+ const parts = address.split("/");
672
+ if (parts.length === 3) {
673
+ lockByNsType.set(`${parts[1]}/${parts[2]}`, pkg);
674
+ }
675
+ }
676
+ const ctx = {
677
+ state,
678
+ rootDir,
679
+ rootRel,
680
+ scanLexRoot: state.scanLexRoot,
681
+ terraformDir: join(rootDir, ".terraform"),
682
+ defaultHost: defaultRegistryHostFor(rootDir, lockByAddress),
683
+ lockByAddress,
684
+ lockByNsType,
685
+ unresolved: 0,
686
+ configOf,
687
+ };
688
+ const rootRef = state.singleRoot ? parentRef : `terraform-root:${rootRel}`;
689
+ ctx.rootRef = rootRef;
690
+ ctx.singleRoot = state.singleRoot;
691
+
692
+ // Every locked provider is a component up front, with its offline license.
693
+ for (const pkg of lockProviders) {
694
+ applyProviderLicense(ctx, pkg);
695
+ mergeIntoState(state, pkg);
696
+ }
697
+
698
+ const manifest = root.manifestFile
699
+ ? readModulesManifest(root.manifestFile, rootDir)
700
+ : undefined;
701
+ const records = [];
702
+ if (manifest) {
703
+ collectManifestRecords(ctx, manifest, records, parseConfigInto);
704
+ } else {
705
+ collectStaticRecords(ctx, records, parseConfigInto, new Set([rootDir]));
706
+ }
707
+ records.sort((a, b) => (a.key < b.key ? -1 : 1));
708
+ const recordByKey = new Map(records.map((record) => [record.key, record]));
709
+ const dirOwner = new Map();
710
+ for (const record of records) {
711
+ if (record.dirValid && record.dirAbs && !dirOwner.has(record.dirAbs)) {
712
+ dirOwner.set(record.dirAbs, record.key);
713
+ }
714
+ }
715
+ if (!dirOwner.has(rootDir)) {
716
+ dirOwner.set(rootDir, "");
717
+ }
718
+
719
+ // Pass 1: components for remote records, so owners resolve regardless of
720
+ // the order the manifest listed parents and children in.
721
+ for (const record of records) {
722
+ if (record.remote) {
723
+ record.ref = emitModuleComponent(ctx, record)?.["bom-ref"];
724
+ }
725
+ }
726
+ // Pass 2: module edges. A local record's owner is its parent's owner; a
727
+ // remote record owns itself.
728
+ const moduleRefs = new Map(
729
+ records.filter((r) => r.ref).map((r) => [r.key, r.ref]),
730
+ );
731
+ const ownerRefForKey = (key) => {
732
+ if (!key) {
733
+ return rootRef;
734
+ }
735
+ const record = recordByKey.get(key);
736
+ if (!record || record.remote) {
737
+ return moduleRefs.get(key);
738
+ }
739
+ return ownerRefForKey(record.parentKey);
740
+ };
741
+ for (const record of records) {
742
+ if (!record.remote || !record.ref) {
743
+ continue;
744
+ }
745
+ const owner = ownerRefForKey(record.parentKey);
746
+ addEdge(state, owner, record.ref);
747
+ }
748
+
749
+ // Required providers of every parsed directory, including the root's.
750
+ for (const [dir, config] of parsedDirs) {
751
+ const ownerKey = dirOwner.get(dir) ?? "";
752
+ emitProviderEdges(ctx, config, dir, ownerRefForKey(ownerKey));
753
+ }
754
+
755
+ // Lock providers nothing in the configuration claims still belong to the
756
+ // root, so the graph stays connected.
757
+ connectOrphanLockProviders(ctx);
758
+
759
+ // Root properties land on the parent for a single root, otherwise on the
760
+ // root application component.
761
+ const rootProperties = [
762
+ { name: "cdx:tf:root", value: rootRel },
763
+ ...[...new Set(rootConfig.requiredVersions)].map((value) => ({
764
+ name: "cdx:tf:requiredVersion",
765
+ value,
766
+ })),
767
+ ];
768
+ if (ctx.unresolved > 0) {
769
+ rootProperties.push({
770
+ name: "cdx:tf:unresolvedModuleCalls",
771
+ value: `${ctx.unresolved}`,
772
+ });
773
+ }
774
+ if (ctx.singleRoot) {
775
+ state.parentProperties.push(...rootProperties);
776
+ } else {
777
+ state.byRef.set(rootRef, {
778
+ type: "application",
779
+ name: rootRel === "." ? basename(resolve(scanPath)) : rootRel,
780
+ "bom-ref": rootRef,
781
+ properties: rootProperties,
782
+ });
783
+ addEdge(state, parentRef, rootRef);
784
+ }
785
+
786
+ const srcFiles = [];
787
+ if (root.lockFile) {
788
+ srcFiles.push(posixRelative(scanPath, root.lockFile));
789
+ }
790
+ if (root.manifestFile) {
791
+ srcFiles.push(posixRelative(scanPath, root.manifestFile));
792
+ }
793
+ state.srcFiles.push(...(srcFiles.length ? srcFiles : [rootRel]));
794
+ }
795
+
796
+ /**
797
+ * The registry host shorthand resolves against: OpenTofu's registry when the
798
+ * lock file uses it or the directory has `.tofu` files.
799
+ *
800
+ * @param {string} rootDir Root directory
801
+ * @param {Map} lockByAddress Lock providers by full address
802
+ * @returns {string}
803
+ */
804
+ function defaultRegistryHostFor(rootDir, lockByAddress) {
805
+ for (const address of lockByAddress.keys()) {
806
+ if (address.startsWith(`${OPENTOFU_REGISTRY_HOST}/`)) {
807
+ return OPENTOFU_REGISTRY_HOST;
808
+ }
809
+ }
810
+ const names = listDirNames(rootDir) || [];
811
+ if (
812
+ names.some((name) => name.endsWith(".tofu") || name.endsWith(".tofu.json"))
813
+ ) {
814
+ return OPENTOFU_REGISTRY_HOST;
815
+ }
816
+ return TERRAFORM_REGISTRY_HOST;
817
+ }
818
+
819
+ /**
820
+ * Collect module records from `modules.json`, plus declared-only calls the
821
+ * manifest does not list.
822
+ *
823
+ * @param {object} ctx Per-root context
824
+ * @param {{ records: object[] }} manifest Parsed manifest
825
+ * @param {object[]} records Output record list
826
+ * @param {Function} parseConfigInto Bounded config loader
827
+ */
828
+ function collectManifestRecords(ctx, manifest, records, parseConfigInto) {
829
+ const { rootDir, scanLexRoot, defaultHost } = ctx;
830
+ const modulesDir = join(rootDir, ".terraform", "modules");
831
+ const byKey = new Map();
832
+ for (const entry of manifest.records) {
833
+ byKey.set(entry.key, { entry });
834
+ }
835
+ if (!byKey.has("")) {
836
+ byKey.set("", { entry: { key: "", source: "", dirRaw: "." } });
837
+ }
838
+ // Resolve directories first so declarations can be found afterwards.
839
+ for (const holder of byKey.values()) {
840
+ const entry = holder.entry;
841
+ const isRootRecord = entry.key === "";
842
+ const parsed = entry.source
843
+ ? parseModuleSource(entry.source, { defaultRegistryHost: defaultHost })
844
+ : undefined;
845
+ const local = isRootRecord || parsed?.kind === "local";
846
+ const remote = !local && REMOTE_KINDS.has(parsed?.kind);
847
+ let dirAbs;
848
+ let dirValid = false;
849
+ let packageRoot;
850
+ if (isRootRecord) {
851
+ dirAbs = rootDir;
852
+ dirValid = true;
853
+ } else if (
854
+ entry.dirRaw &&
855
+ !entry.dirRaw.includes("\0") &&
856
+ !isAbsolute(entry.dirRaw)
857
+ ) {
858
+ const target = join(rootDir, ...entry.dirRaw.split("/"));
859
+ if (isInsideDir(local ? scanLexRoot : modulesDir, target)) {
860
+ dirAbs = target;
861
+ dirValid = true;
862
+ }
863
+ if (entry.dirRaw.startsWith(".terraform/modules/")) {
864
+ const first = entry.dirRaw
865
+ .slice(".terraform/modules/".length)
866
+ .split("/")[0];
867
+ if (first) {
868
+ packageRoot = join(modulesDir, first);
869
+ }
870
+ }
871
+ }
872
+ holder.record = {
873
+ key: entry.key,
874
+ parentKey: parentKeyOf(entry.key),
875
+ name: entry.key.split(".").pop(),
876
+ remote,
877
+ parsed: remote ? parsed : undefined,
878
+ version: entry.version,
879
+ installed: true,
880
+ dirAbs,
881
+ dirValid,
882
+ packageRoot,
883
+ };
884
+ records.push(holder.record);
885
+ if (dirValid) {
886
+ parseConfigInto(dirAbs);
887
+ }
888
+ }
889
+ // Declarations live in the merged config of each record's parent.
890
+ for (const record of records) {
891
+ if (!record.remote) {
892
+ continue;
893
+ }
894
+ const parentDir = byKey.get(record.parentKey)?.record?.dirValid
895
+ ? byKey.get(record.parentKey).record.dirAbs
896
+ : undefined;
897
+ if (!parentDir) {
898
+ continue;
899
+ }
900
+ const call = parseConfigInto(parentDir).moduleCalls.find(
901
+ (candidate) => candidate.name === record.name,
902
+ );
903
+ if (call) {
904
+ record.declaration = call;
905
+ record.declarationDir = parentDir;
906
+ }
907
+ }
908
+ // Remote calls the manifest does not list are declared-only components.
909
+ for (const record of [...records]) {
910
+ if (!record.dirValid || !record.dirAbs) {
911
+ continue;
912
+ }
913
+ const ownerKey = manifestOwnerKey(records, record.dirAbs);
914
+ for (const call of parseConfigInto(record.dirAbs).moduleCalls) {
915
+ const expectedKey = ownerKey ? `${ownerKey}.${call.name}` : call.name;
916
+ if (byKey.has(expectedKey)) {
917
+ continue;
918
+ }
919
+ if (!call.source || call.sourceLiteral !== true) {
920
+ continue;
921
+ }
922
+ const parsed = parseModuleSource(call.source, {
923
+ defaultRegistryHost: defaultHost,
924
+ });
925
+ if (!REMOTE_KINDS.has(parsed?.kind)) {
926
+ continue;
927
+ }
928
+ const declared = {
929
+ key: expectedKey,
930
+ parentKey: ownerKey,
931
+ name: call.name,
932
+ remote: true,
933
+ parsed,
934
+ installed: false,
935
+ dirAbs: undefined,
936
+ dirValid: false,
937
+ declaration: call,
938
+ declarationDir: record.dirAbs,
939
+ };
940
+ records.push(declared);
941
+ byKey.set(expectedKey, { entry: declared, record: declared });
942
+ }
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Key of the record that owns a directory, preferring the shortest key so
948
+ * the root record wins ties.
949
+ *
950
+ * @param {object[]} records Records with resolved directories
951
+ * @param {string} dir Directory
952
+ * @returns {string} Owning key ("" for the root)
953
+ */
954
+ function manifestOwnerKey(records, dir) {
955
+ let best;
956
+ for (const record of records) {
957
+ if (record.dirValid && record.dirAbs === dir) {
958
+ if (best === undefined || record.key.length < best.length) {
959
+ best = record.key;
960
+ }
961
+ }
962
+ }
963
+ return best || "";
964
+ }
965
+
966
+ /**
967
+ * Walk the configuration statically when no manifest exists. Local module
968
+ * calls are flattened into the calling root, so every edge produced here
969
+ * hangs off the root ref.
970
+ *
971
+ * @param {object} ctx Per-root context
972
+ * @param {object[]} records Output record list
973
+ * @param {Function} parseConfigInto Bounded config loader
974
+ * @param {Set<string>} visited Absolute directories already walked
975
+ */
976
+ function collectStaticRecords(ctx, records, parseConfigInto, visited) {
977
+ const { rootDir, scanLexRoot, defaultHost } = ctx;
978
+ const walk = (dir, keyPrefix, depth) => {
979
+ for (const call of parseConfigInto(dir).moduleCalls) {
980
+ if (!call.source || call.sourceLiteral !== true) {
981
+ ctx.unresolved += 1;
982
+ continue;
983
+ }
984
+ const parsed = parseModuleSource(call.source, {
985
+ defaultRegistryHost: defaultHost,
986
+ });
987
+ if (parsed.kind === "local") {
988
+ const target = resolve(dir, parsed.path);
989
+ if (!isInsideDir(scanLexRoot, target)) {
990
+ ctx.unresolved += 1;
991
+ continue;
992
+ }
993
+ if (visited.has(target) || depth >= MAX_LOCAL_DEPTH) {
994
+ continue;
995
+ }
996
+ visited.add(target);
997
+ walk(target, `${keyPrefix}${call.name}.`, depth + 1);
998
+ continue;
999
+ }
1000
+ if (!REMOTE_KINDS.has(parsed.kind)) {
1001
+ ctx.unresolved += 1;
1002
+ continue;
1003
+ }
1004
+ records.push({
1005
+ key: keyPrefix ? `${keyPrefix}${call.name}` : call.name,
1006
+ // Local children are flattened into the calling root, so every
1007
+ // static-flow module hangs off the root ref.
1008
+ parentKey: "",
1009
+ name: call.name,
1010
+ remote: true,
1011
+ parsed,
1012
+ installed: false,
1013
+ dirAbs: undefined,
1014
+ dirValid: false,
1015
+ declaration: call,
1016
+ declarationDir: dir,
1017
+ });
1018
+ }
1019
+ };
1020
+ walk(rootDir, "", 0);
1021
+ thoughtLog(
1022
+ `Terraform static walk from ${rootDir} produced ${records.length} remote module records.`,
1023
+ );
1024
+ }
1025
+
1026
+ /**
1027
+ * Turn one remote module record into a component, or count it unresolved.
1028
+ *
1029
+ * @param {object} ctx Per-root context
1030
+ * @param {object} record Module record
1031
+ * @returns {object|undefined} The merged component
1032
+ */
1033
+ function emitModuleComponent(ctx, record) {
1034
+ const { state } = ctx;
1035
+ const { scanPath } = state;
1036
+ const parsed = record.parsed;
1037
+ const pkg = {
1038
+ type: "library",
1039
+ scope: "required",
1040
+ properties: [
1041
+ { name: "cdx:tf:kind", value: "module" },
1042
+ { name: "cdx:tf:module:sourceType", value: parsed.kind },
1043
+ {
1044
+ name: "cdx:tf:module:installed",
1045
+ value: record.installed ? "true" : "false",
1046
+ },
1047
+ ],
1048
+ };
1049
+ if (parsed.displaySource) {
1050
+ pkg.properties.push({
1051
+ name: "cdx:tf:module:source",
1052
+ value: parsed.displaySource,
1053
+ });
1054
+ }
1055
+ const manifestRel = posixRelative(
1056
+ scanPath,
1057
+ join(ctx.rootDir, ".terraform", "modules", "modules.json"),
1058
+ );
1059
+ let identityFile = manifestRel;
1060
+ let confidence = 0.6;
1061
+ if (parsed.kind === "registry") {
1062
+ let version;
1063
+ if (record.installed) {
1064
+ version = record.version;
1065
+ if (version) {
1066
+ confidence = 1;
1067
+ }
1068
+ } else {
1069
+ const exact = classifyVersionConstraint(record.declaration?.version);
1070
+ version = exact.pinning === "exact" ? exact.version : undefined;
1071
+ identityFile = declarationRelPath(scanPath, record) || manifestRel;
1072
+ }
1073
+ const purl = tryBuildPurl({
1074
+ type: "generic",
1075
+ namespace: `${parsed.host}/${parsed.namespace}/${parsed.name}`,
1076
+ name: parsed.system,
1077
+ version: version || null,
1078
+ subpath: parsed.subdir || null,
1079
+ });
1080
+ if (!purl) {
1081
+ ctx.unresolved += 1;
1082
+ return undefined;
1083
+ }
1084
+ pkg.group = `${parsed.host}/${parsed.namespace}`;
1085
+ pkg.name = `${parsed.name}/${parsed.system}`;
1086
+ if (version) {
1087
+ pkg.version = version;
1088
+ }
1089
+ pkg.properties.push(
1090
+ { name: "cdx:purl:proposedType", value: "terraform-module" },
1091
+ { name: "cdx:tf:address", value: parsed.address },
1092
+ );
1093
+ applyPurl(pkg, purl, `library:${parsed.address}:${version || ""}`);
1094
+ } else if (parsed.kind === "git" || parsed.kind === "hg") {
1095
+ const ref = parsed.ref;
1096
+ const safeRef = isSafeGitRefName(ref) ? ref : undefined;
1097
+ const commit =
1098
+ record.installed && record.dirValid
1099
+ ? installedCommitFor(ctx, record)
1100
+ : undefined;
1101
+ const version = safeRef || commit || undefined;
1102
+ const coordinates = parsed.url
1103
+ ? submodulePurlCoordinates(parsed.url, version)
1104
+ : null;
1105
+ const purl = coordinates
1106
+ ? tryBuildPurl({ ...coordinates, subpath: parsed.subdir || null })
1107
+ : null;
1108
+ if (!purl) {
1109
+ ctx.unresolved += 1;
1110
+ return undefined;
1111
+ }
1112
+ if (coordinates.namespace) {
1113
+ pkg.group = coordinates.namespace;
1114
+ }
1115
+ pkg.name = coordinates.name;
1116
+ if (version) {
1117
+ pkg.version = version;
1118
+ }
1119
+ if (safeRef) {
1120
+ pkg.properties.push({ name: "cdx:tf:module:ref", value: safeRef });
1121
+ }
1122
+ if (commit) {
1123
+ pkg.properties.push({ name: "cdx:tf:module:commit", value: commit });
1124
+ }
1125
+ pkg.properties.push({
1126
+ name: "cdx:tf:module:pinning",
1127
+ value: classifyVcsRef(ref),
1128
+ });
1129
+ applyPurl(pkg, purl, `library:${parsed.displaySource}:${version || ""}`);
1130
+ if (!record.installed) {
1131
+ identityFile = declarationRelPath(scanPath, record) || manifestRel;
1132
+ } else if (version) {
1133
+ confidence = 1;
1134
+ }
1135
+ } else {
1136
+ // Archive module over http/s3/gcs.
1137
+ const base = archiveBaseName(parsed.url);
1138
+ const host = urlHost(parsed.url);
1139
+ const purl =
1140
+ base && host
1141
+ ? tryBuildPurl({
1142
+ type: "generic",
1143
+ namespace: host,
1144
+ name: base,
1145
+ qualifiers: { download_url: parsed.url },
1146
+ subpath: parsed.subdir || null,
1147
+ })
1148
+ : null;
1149
+ if (!purl) {
1150
+ ctx.unresolved += 1;
1151
+ return undefined;
1152
+ }
1153
+ pkg.group = host;
1154
+ pkg.name = base;
1155
+ pkg.distribution = { url: parsed.url };
1156
+ if (parsed.checksum) {
1157
+ pkg.hashes = [
1158
+ { alg: parsed.checksum.alg, content: parsed.checksum.content },
1159
+ ];
1160
+ }
1161
+ pkg.properties.push({
1162
+ name: "cdx:tf:module:pinning",
1163
+ value: parsed.checksum ? "checksum" : "none",
1164
+ });
1165
+ applyPurl(pkg, purl, `library:${parsed.displaySource}`);
1166
+ if (!record.installed) {
1167
+ identityFile = declarationRelPath(scanPath, record) || manifestRel;
1168
+ }
1169
+ }
1170
+ if (record.declaration?.version) {
1171
+ pkg.properties.push({
1172
+ name: "cdx:tf:constraints",
1173
+ value: record.declaration.version,
1174
+ });
1175
+ if (parsed.kind === "registry") {
1176
+ pkg.properties.push({
1177
+ name: "cdx:tf:module:pinning",
1178
+ value: classifyVersionConstraint(record.declaration.version).pinning,
1179
+ });
1180
+ }
1181
+ } else if (parsed.kind === "registry" && record.declaration) {
1182
+ // The call was found but sets no version constraint.
1183
+ pkg.properties.push({ name: "cdx:tf:module:pinning", value: "none" });
1184
+ }
1185
+ if (parsed.credentialInSource) {
1186
+ pkg.properties.push({
1187
+ name: "cdx:tf:module:credentialInSource",
1188
+ value: "true",
1189
+ });
1190
+ }
1191
+ const srcFile =
1192
+ record.declaration && record.declarationDir
1193
+ ? join(record.declarationDir, record.declaration.file)
1194
+ : join(ctx.rootDir, ".terraform", "modules", "modules.json");
1195
+ pkg.properties.push({ name: "internal:SrcFile", value: srcFile });
1196
+ const occurrences = [];
1197
+ if (record.declaration && record.declarationDir) {
1198
+ const occurrence = {
1199
+ location: posixRelative(scanPath, srcFile),
1200
+ symbol: `module.${record.name}`,
1201
+ };
1202
+ if (!record.declaration.file.endsWith(".json") && record.declaration.line) {
1203
+ occurrence.line = record.declaration.line;
1204
+ }
1205
+ occurrences.push(occurrence);
1206
+ }
1207
+ pkg.evidence = {
1208
+ identity: {
1209
+ field: "purl",
1210
+ confidence,
1211
+ methods: [
1212
+ {
1213
+ technique: "manifest-analysis",
1214
+ confidence,
1215
+ value: identityFile,
1216
+ },
1217
+ ],
1218
+ },
1219
+ ...(occurrences.length ? { occurrences } : {}),
1220
+ };
1221
+ // Offline license from the installed package.
1222
+ if (record.installed && record.dirValid && record.dirAbs) {
1223
+ applyModuleLicense(ctx, pkg, record);
1224
+ }
1225
+ return mergeIntoState(state, pkg);
1226
+ }
1227
+
1228
+ /**
1229
+ * Relative POSIX path of a record's declaration file.
1230
+ *
1231
+ * @param {string} scanPath Scan root
1232
+ * @param {object} record Module record
1233
+ * @returns {string|undefined}
1234
+ */
1235
+ function declarationRelPath(scanPath, record) {
1236
+ if (record.declaration && record.declarationDir) {
1237
+ return posixRelative(
1238
+ scanPath,
1239
+ join(record.declarationDir, record.declaration.file),
1240
+ );
1241
+ }
1242
+ return undefined;
1243
+ }
1244
+
1245
+ /**
1246
+ * Read the installed commit of a VCS module package from `.git/HEAD`.
1247
+ *
1248
+ * @param {object} ctx Per-root context
1249
+ * @param {object} record Module record
1250
+ * @returns {string|undefined} 40/64-hex commit
1251
+ */
1252
+ function installedCommitFor(ctx, record) {
1253
+ if (!record.packageRoot) {
1254
+ return undefined;
1255
+ }
1256
+ const head = readFileWithin(
1257
+ join(record.packageRoot, ".git", "HEAD"),
1258
+ join(ctx.rootDir, ".terraform"),
1259
+ GIT_HEAD_MAX_BYTES,
1260
+ );
1261
+ if (head === undefined) {
1262
+ return undefined;
1263
+ }
1264
+ const trimmed = head.trim();
1265
+ // Never follow `ref:` lines; only a detached sha is usable.
1266
+ if (/^[0-9a-f]{40}$/u.test(trimmed) || /^[0-9a-f]{64}$/u.test(trimmed)) {
1267
+ return trimmed;
1268
+ }
1269
+ return undefined;
1270
+ }
1271
+
1272
+ /**
1273
+ * Strip the archive extension from the last path segment of a URL.
1274
+ *
1275
+ * @param {string} url Sanitized archive URL
1276
+ * @returns {string|undefined} Basename without extension
1277
+ */
1278
+ function archiveBaseName(url) {
1279
+ try {
1280
+ const segments = new URL(url).pathname.split("/").filter(Boolean);
1281
+ const last = segments[segments.length - 1];
1282
+ if (!last) {
1283
+ return undefined;
1284
+ }
1285
+ const lower = last.toLowerCase();
1286
+ for (const ext of ARCHIVE_EXTENSIONS) {
1287
+ if (lower.endsWith(ext)) {
1288
+ return last.slice(0, -ext.length) || undefined;
1289
+ }
1290
+ }
1291
+ return last;
1292
+ } catch {
1293
+ return undefined;
1294
+ }
1295
+ }
1296
+
1297
+ /**
1298
+ * Hostname of a sanitized URL.
1299
+ *
1300
+ * @param {string} url URL
1301
+ * @returns {string|undefined}
1302
+ */
1303
+ function urlHost(url) {
1304
+ try {
1305
+ return new URL(url).hostname;
1306
+ } catch {
1307
+ return undefined;
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * Map one directory's required providers onto provider components and edges.
1313
+ *
1314
+ * @param {object} ctx Per-root context
1315
+ * @param {object} config Merged configuration of the directory
1316
+ * @param {string} dir Directory absolute path
1317
+ * @param {string} ownerRef Owner bom-ref
1318
+ */
1319
+ function emitProviderEdges(ctx, config, dir, ownerRef) {
1320
+ for (const entry of config.requiredProviders || []) {
1321
+ let host;
1322
+ let namespace;
1323
+ let type;
1324
+ // A source that spells out its host (`registry.opentofu.org/hashicorp/aws`)
1325
+ // names exactly one provider; only shorthand may match a lock entry on
1326
+ // another host by namespace/type.
1327
+ let explicitHost = false;
1328
+ if (entry.source) {
1329
+ const parsed = parseProviderSource(entry.source, {
1330
+ defaultRegistryHost: ctx.defaultHost,
1331
+ });
1332
+ if (parsed?.builtin) {
1333
+ continue;
1334
+ }
1335
+ if (parsed) {
1336
+ host = parsed.host;
1337
+ namespace = parsed.namespace;
1338
+ type = parsed.type;
1339
+ explicitHost = entry.source.trim().split("/").length === 3;
1340
+ }
1341
+ }
1342
+ if (!type) {
1343
+ // Terraform's implied fallback for the legacy and source-less forms.
1344
+ host = ctx.defaultHost;
1345
+ namespace = "hashicorp";
1346
+ type = entry.localName;
1347
+ }
1348
+ const address = `${host}/${namespace}/${type}`;
1349
+ const pkg =
1350
+ ctx.lockByAddress.get(address) ||
1351
+ (explicitHost
1352
+ ? undefined
1353
+ : ctx.lockByNsType.get(`${namespace}/${type}`)) ||
1354
+ buildConfigOnlyProvider(ctx, host, namespace, type, entry, dir);
1355
+ if (!pkg) {
1356
+ continue;
1357
+ }
1358
+ const merged = mergeIntoState(ctx.state, pkg);
1359
+ const occurrence = {
1360
+ location: posixRelative(ctx.state.scanPath, join(dir, entry.file)),
1361
+ symbol: `required_providers.${entry.localName}`,
1362
+ };
1363
+ if (!entry.file.endsWith(".json") && entry.line) {
1364
+ occurrence.line = entry.line;
1365
+ }
1366
+ addOccurrence(merged, occurrence);
1367
+ addEdge(ctx.state, ownerRef, merged["bom-ref"]);
1368
+ }
1369
+ }
1370
+
1371
+ /**
1372
+ * Build a provider component that only exists in `required_providers`.
1373
+ *
1374
+ * @param {object} ctx Per-root context
1375
+ * @param {string} host Registry host
1376
+ * @param {string} namespace Registry namespace
1377
+ * @param {string} type Provider type
1378
+ * @param {object} entry Merged required_providers entry
1379
+ * @param {string} dir Directory the requirement lives in
1380
+ * @returns {object|undefined} Component
1381
+ */
1382
+ function buildConfigOnlyProvider(ctx, host, namespace, type, entry, dir) {
1383
+ const exact = classifyVersionConstraint(entry.version);
1384
+ const version = exact.pinning === "exact" ? exact.version : undefined;
1385
+ const address = `${host}/${namespace}/${type}`;
1386
+ const purl = tryBuildPurl({
1387
+ type: "generic",
1388
+ namespace: `${host}/${namespace}`,
1389
+ name: type,
1390
+ version: version || null,
1391
+ });
1392
+ if (!purl) {
1393
+ return undefined;
1394
+ }
1395
+ const srcFile = join(dir, entry.file);
1396
+ const occurrence = {
1397
+ location: posixRelative(ctx.state.scanPath, srcFile),
1398
+ symbol: `required_providers.${entry.localName}`,
1399
+ };
1400
+ if (!entry.file.endsWith(".json") && entry.line) {
1401
+ occurrence.line = entry.line;
1402
+ }
1403
+ const pkg = {
1404
+ group: `${host}/${namespace}`,
1405
+ name: type,
1406
+ ...(version ? { version } : {}),
1407
+ type: "library",
1408
+ scope: "required",
1409
+ properties: [
1410
+ { name: "internal:SrcFile", value: srcFile },
1411
+ { name: "cdx:purl:proposedType", value: "terraform-provider" },
1412
+ { name: "cdx:tf:kind", value: "provider" },
1413
+ { name: "cdx:tf:address", value: address },
1414
+ ...(entry.version
1415
+ ? [{ name: "cdx:tf:constraints", value: entry.version }]
1416
+ : []),
1417
+ ],
1418
+ evidence: {
1419
+ identity: {
1420
+ field: "purl",
1421
+ confidence: 0.6,
1422
+ methods: [
1423
+ {
1424
+ technique: "manifest-analysis",
1425
+ confidence: 0.6,
1426
+ value: posixRelative(ctx.state.scanPath, srcFile),
1427
+ },
1428
+ ],
1429
+ },
1430
+ occurrences: [occurrence],
1431
+ },
1432
+ };
1433
+ applyPurl(pkg, purl, `library:${address}:${version || ""}`);
1434
+ return pkg;
1435
+ }
1436
+
1437
+ /**
1438
+ * Give lock providers nothing in the configuration claims an edge to the
1439
+ * root, so the graph stays connected.
1440
+ *
1441
+ * @param {object} ctx Per-root context
1442
+ */
1443
+ function connectOrphanLockProviders(ctx) {
1444
+ const { state, rootRef, lockByAddress } = ctx;
1445
+ const seen = new Set([rootRef]);
1446
+ const queue = [rootRef];
1447
+ while (queue.length) {
1448
+ const ref = queue.shift();
1449
+ for (const dep of state.edges.get(ref) || []) {
1450
+ if (!seen.has(dep)) {
1451
+ seen.add(dep);
1452
+ queue.push(dep);
1453
+ }
1454
+ }
1455
+ }
1456
+ // Every locked provider, keyed by full address: two providers that share a
1457
+ // namespace/type on different hosts are both components and both need an
1458
+ // owner.
1459
+ for (const pkg of lockByAddress.values()) {
1460
+ if (!seen.has(pkg["bom-ref"])) {
1461
+ addEdge(state, rootRef, pkg["bom-ref"]);
1462
+ }
1463
+ }
1464
+ }
1465
+
1466
+ /**
1467
+ * Find and apply the offline license of an installed module package.
1468
+ *
1469
+ * @param {object} ctx Per-root context
1470
+ * @param {object} pkg Component under construction
1471
+ * @param {object} record Module record
1472
+ */
1473
+ function applyModuleLicense(ctx, pkg, record) {
1474
+ const allowedRoot = join(ctx.rootDir, ".terraform");
1475
+ const searchDirs = [];
1476
+ if (record.dirAbs) {
1477
+ searchDirs.push(record.dirAbs);
1478
+ }
1479
+ if (record.packageRoot && record.packageRoot !== record.dirAbs) {
1480
+ searchDirs.push(record.packageRoot);
1481
+ }
1482
+ for (const dir of searchDirs) {
1483
+ const found = findLicenseInDir(dir, allowedRoot);
1484
+ if (found) {
1485
+ applyLicense(ctx, pkg, found.file, found.text);
1486
+ return;
1487
+ }
1488
+ }
1489
+ }
1490
+
1491
+ /**
1492
+ * Find and apply the offline license of an installed provider.
1493
+ *
1494
+ * @param {object} ctx Per-root context
1495
+ * @param {object} pkg Provider component
1496
+ */
1497
+ function applyProviderLicense(ctx, pkg) {
1498
+ if (!pkg.version) {
1499
+ return;
1500
+ }
1501
+ const groupParts = (pkg.group || "").split("/");
1502
+ if (groupParts.length !== 2) {
1503
+ return;
1504
+ }
1505
+ const segments = [...groupParts, pkg.name, pkg.version];
1506
+ for (const segment of segments) {
1507
+ if (
1508
+ !segment ||
1509
+ segment.length > 255 ||
1510
+ segment.includes("/") ||
1511
+ segment.includes("\\") ||
1512
+ segment.includes("..") ||
1513
+ segment.includes("\0")
1514
+ ) {
1515
+ return;
1516
+ }
1517
+ }
1518
+ const allowedRoot = join(ctx.rootDir, ".terraform");
1519
+ const providerDir = join(ctx.rootDir, ".terraform", "providers", ...segments);
1520
+ let entries;
1521
+ try {
1522
+ entries = readdirSync(providerDir, { withFileTypes: true });
1523
+ } catch {
1524
+ return;
1525
+ }
1526
+ const platforms = entries
1527
+ .filter(
1528
+ (entry) =>
1529
+ entry.isDirectory() &&
1530
+ !entry.name.endsWith(".lock") &&
1531
+ !entry.isSymbolicLink(),
1532
+ )
1533
+ .map((entry) => entry.name)
1534
+ .sort();
1535
+ for (const platform of platforms) {
1536
+ const found = findLicenseInDir(join(providerDir, platform), allowedRoot);
1537
+ if (found) {
1538
+ applyLicense(ctx, pkg, found.file, found.text);
1539
+ return;
1540
+ }
1541
+ }
1542
+ }
1543
+
1544
+ /**
1545
+ * Locate a license file in one directory, in the documented name order.
1546
+ *
1547
+ * @param {string} dir Directory to inspect
1548
+ * @param {string} allowedRoot Read containment root
1549
+ * @returns {undefined|{ file: string, text: string }} License descriptor
1550
+ */
1551
+ function findLicenseInDir(dir, allowedRoot) {
1552
+ const names = listDirNames(dir);
1553
+ if (!names) {
1554
+ return undefined;
1555
+ }
1556
+ const lower = new Map(names.map((name) => [name.toLowerCase(), name]));
1557
+ for (const candidate of LICENSE_CANDIDATES) {
1558
+ const actual = lower.get(candidate.toLowerCase());
1559
+ if (!actual) {
1560
+ continue;
1561
+ }
1562
+ const file = join(dir, actual);
1563
+ const text = readFileWithin(file, allowedRoot, LICENSE_MAX_BYTES);
1564
+ if (text !== undefined) {
1565
+ return { file, text };
1566
+ }
1567
+ }
1568
+ return undefined;
1569
+ }
1570
+
1571
+ /**
1572
+ * Record an identified license on a component.
1573
+ *
1574
+ * @param {object} ctx Per-root context
1575
+ * @param {object} pkg Component
1576
+ * @param {string} file Absolute license path
1577
+ * @param {string} text License text
1578
+ */
1579
+ function applyLicense(ctx, pkg, file, text) {
1580
+ const rel = posixRelative(ctx.state.scanPath, file);
1581
+ const id = licenseIdFromText(text);
1582
+ if (id && !pkg.license) {
1583
+ pkg.license = id;
1584
+ pkg.properties.push({ name: "cdx:tf:licenseSource", value: "file" });
1585
+ }
1586
+ pkg.properties.push({ name: "cdx:tf:licenseFile", value: rel });
1587
+ }
1588
+
1589
+ /**
1590
+ * Merge a component into the workspace state, deduplicating by bom-ref.
1591
+ *
1592
+ * @param {object} state Workspace accumulator
1593
+ * @param {object} pkg Component
1594
+ * @returns {object} The merged component
1595
+ */
1596
+ function mergeIntoState(state, pkg) {
1597
+ const ref = pkg["bom-ref"];
1598
+ const existing = state.byRef.get(ref);
1599
+ if (!existing) {
1600
+ state.byRef.set(ref, pkg);
1601
+ if (!state.edges.has(ref)) {
1602
+ state.edges.set(ref, new Set());
1603
+ }
1604
+ return pkg;
1605
+ }
1606
+ mergeProperties(existing, pkg.properties || []);
1607
+ for (const occurrence of pkg.evidence?.occurrences || []) {
1608
+ addOccurrence(existing, occurrence);
1609
+ }
1610
+ // A component seen again (another root, or the installed copy of a module
1611
+ // first seen as declared-only) may carry what the first emission could not
1612
+ // read: a license file, more lock digests, a stronger identity. Its
1613
+ // `cdx:tf:licenseSource` property is merged above, so the license it names
1614
+ // must come along, or the BOM claims a file license it does not carry.
1615
+ if (!existing.license && pkg.license) {
1616
+ existing.license = pkg.license;
1617
+ }
1618
+ if (pkg.hashes?.length) {
1619
+ const known = new Set(
1620
+ (existing.hashes || []).map((hash) => `${hash.alg}:${hash.content}`),
1621
+ );
1622
+ for (const hash of pkg.hashes) {
1623
+ if (!known.has(`${hash.alg}:${hash.content}`)) {
1624
+ known.add(`${hash.alg}:${hash.content}`);
1625
+ (existing.hashes = existing.hashes || []).push(hash);
1626
+ }
1627
+ }
1628
+ }
1629
+ if (!existing.distribution && pkg.distribution) {
1630
+ existing.distribution = pkg.distribution;
1631
+ }
1632
+ const incomingIdentity = pkg.evidence?.identity;
1633
+ if (
1634
+ incomingIdentity &&
1635
+ (incomingIdentity.confidence || 0) >
1636
+ (existing.evidence?.identity?.confidence || 0)
1637
+ ) {
1638
+ existing.evidence = { ...existing.evidence, identity: incomingIdentity };
1639
+ }
1640
+ return existing;
1641
+ }
1642
+
1643
+ /**
1644
+ * Union two property lists by name+value, with `installed=true` replacing
1645
+ * `installed=false`.
1646
+ *
1647
+ * @param {object} existing Component accumulated so far
1648
+ * @param {object[]} incoming Properties of the duplicate
1649
+ */
1650
+ function mergeProperties(existing, incoming) {
1651
+ const merged = [...(existing.properties || [])];
1652
+ for (const prop of incoming) {
1653
+ if (prop.name === "cdx:tf:module:installed") {
1654
+ const index = merged.findIndex(
1655
+ (p) => p.name === "cdx:tf:module:installed",
1656
+ );
1657
+ if (index === -1) {
1658
+ merged.push(prop);
1659
+ } else if (merged[index].value !== "true" && prop.value === "true") {
1660
+ merged.splice(index, 1, prop);
1661
+ }
1662
+ continue;
1663
+ }
1664
+ if (!merged.some((p) => p.name === prop.name && p.value === prop.value)) {
1665
+ merged.push(prop);
1666
+ }
1667
+ }
1668
+ existing.properties = merged;
1669
+ }
1670
+
1671
+ /**
1672
+ * Add one occurrence, deduplicated and capped.
1673
+ *
1674
+ * @param {object} pkg Component
1675
+ * @param {{ location: string, line?: number, symbol: string }} occurrence
1676
+ */
1677
+ function addOccurrence(pkg, occurrence) {
1678
+ pkg.evidence = pkg.evidence || {
1679
+ identity: {
1680
+ field: "purl",
1681
+ confidence: 0.6,
1682
+ methods: [{ technique: "manifest-analysis", confidence: 0.6, value: "" }],
1683
+ },
1684
+ };
1685
+ const occurrences = (pkg.evidence.occurrences =
1686
+ pkg.evidence.occurrences || []);
1687
+ const key = `${occurrence.location}|${occurrence.line || ""}|${occurrence.symbol}`;
1688
+ if (
1689
+ occurrences.some((o) => `${o.location}|${o.line || ""}|${o.symbol}` === key)
1690
+ ) {
1691
+ return;
1692
+ }
1693
+ occurrences.push(occurrence);
1694
+ occurrences.sort(compareOccurrences);
1695
+ if (occurrences.length > MAX_OCCURRENCES) {
1696
+ occurrences.length = MAX_OCCURRENCES;
1697
+ }
1698
+ }
1699
+
1700
+ /**
1701
+ * Order occurrences by location, then line, then symbol, so the list and the
1702
+ * cap applied to it are the same on every run.
1703
+ *
1704
+ * @param {{ location: string, line?: number, symbol: string }} a
1705
+ * @param {{ location: string, line?: number, symbol: string }} b
1706
+ * @returns {number}
1707
+ */
1708
+ function compareOccurrences(a, b) {
1709
+ if (a.location !== b.location) {
1710
+ return a.location < b.location ? -1 : 1;
1711
+ }
1712
+ const lineDiff = (a.line || 0) - (b.line || 0);
1713
+ if (lineDiff) {
1714
+ return lineDiff;
1715
+ }
1716
+ if (a.symbol === b.symbol) {
1717
+ return 0;
1718
+ }
1719
+ return a.symbol < b.symbol ? -1 : 1;
1720
+ }
1721
+
1722
+ /**
1723
+ * Add a dependency edge.
1724
+ *
1725
+ * @param {object} state Workspace accumulator
1726
+ * @param {string} from Ref that depends
1727
+ * @param {string} to Ref depended on
1728
+ */
1729
+ function addEdge(state, from, to) {
1730
+ if (!from || !to || from === to) {
1731
+ return;
1732
+ }
1733
+ if (!state.edges.has(from)) {
1734
+ state.edges.set(from, new Set());
1735
+ }
1736
+ state.edges.get(from).add(to);
1737
+ }
1738
+
1739
+ /**
1740
+ * Read a component property.
1741
+ *
1742
+ * @param {object} pkg Component
1743
+ * @param {string} name Property name
1744
+ * @returns {string|undefined}
1745
+ */
1746
+ function findProperty(pkg, name) {
1747
+ return (pkg.properties || []).find((prop) => prop.name === name)?.value;
1748
+ }