@cdxgen/cdxgen 13.0.1 → 13.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/README.md +30 -16
  2. package/bin/audit.js +6 -0
  3. package/bin/cdxgen.js +141 -7
  4. package/bin/evinse.js +3 -1
  5. package/data/component-tags.json +8 -1
  6. package/data/crypto-oid.json +88 -12
  7. package/data/frameworks-list.json +86 -1
  8. package/data/remediations.json +1167 -0
  9. package/data/rules/ai-bom.yaml +78 -0
  10. package/data/rules/asar-archives.yaml +1 -1
  11. package/data/rules/build-fidelity.yaml +782 -0
  12. package/data/rules/cargo-rust.yaml +132 -0
  13. package/data/rules/mcp-servers.yaml +122 -0
  14. package/data/rules/package-integrity.yaml +13 -13
  15. package/lib/audit/index.js +48 -4
  16. package/lib/audit/scoring.js +2 -8
  17. package/lib/audit/targets.js +6 -1
  18. package/lib/cli/bomAssembly.js +32 -1
  19. package/lib/cli/cliOptions.js +94 -7
  20. package/lib/cli/index.js +268 -49
  21. package/lib/cli/jsBom.js +91 -5
  22. package/lib/cli/jvmBom.js +1515 -1314
  23. package/lib/cli/managedBom.js +249 -28
  24. package/lib/cli/nativeBom.js +986 -10
  25. package/lib/core/activity.js +120 -2
  26. package/lib/core/buildLedger.js +799 -0
  27. package/lib/core/deferredExit.js +141 -0
  28. package/lib/core/env.js +108 -2
  29. package/lib/core/fs.js +60 -0
  30. package/lib/core/logger.js +12 -99
  31. package/lib/core/severity.js +16 -0
  32. package/lib/core/syncFileWriter.js +106 -0
  33. package/lib/core/userAgent.js +151 -0
  34. package/lib/ecosystems/cargoCache.js +364 -0
  35. package/lib/ecosystems/caxa.js +13 -5
  36. package/lib/ecosystems/cppEvidence.js +40 -2
  37. package/lib/ecosystems/ecosystems.js +250 -91
  38. package/lib/ecosystems/golem.js +11 -1
  39. package/lib/ecosystems/gradleutils.js +498 -17
  40. package/lib/ecosystems/kosi.js +1256 -0
  41. package/lib/ecosystems/parsers-crystal.js +179 -0
  42. package/lib/ecosystems/parsers-dotnet.js +69 -39
  43. package/lib/ecosystems/parsers-elm.js +816 -0
  44. package/lib/ecosystems/parsers-gleam.js +1 -1
  45. package/lib/ecosystems/parsers-js.js +26 -0
  46. package/lib/ecosystems/parsers-julia.js +268 -0
  47. package/lib/ecosystems/parsers-jvm.js +9 -0
  48. package/lib/ecosystems/parsers-lua.js +218 -0
  49. package/lib/ecosystems/parsers-misc.js +188 -2
  50. package/lib/ecosystems/parsers-nim.js +221 -0
  51. package/lib/ecosystems/parsers-opam.js +180 -0
  52. package/lib/ecosystems/parsers-perl.js +277 -0
  53. package/lib/ecosystems/parsers-python.js +172 -36
  54. package/lib/ecosystems/parsers-r.js +148 -0
  55. package/lib/ecosystems/parsers-rebar.js +411 -0
  56. package/lib/ecosystems/parsers-rust.js +944 -19
  57. package/lib/ecosystems/parsers-spack.js +204 -0
  58. package/lib/ecosystems/parsers-stack.js +264 -0
  59. package/lib/ecosystems/parsers-terraform.js +194 -0
  60. package/lib/ecosystems/pypiClassifiers.js +326 -0
  61. package/lib/ecosystems/pythonutils.js +49 -1
  62. package/lib/ecosystems/rubyutils.js +13 -0
  63. package/lib/ecosystems/rusi.js +126 -6
  64. package/lib/evinser/evinser.js +141 -13
  65. package/lib/helpers/agenticToolutils.js +925 -0
  66. package/lib/helpers/remote/dependency-track.js +81 -14
  67. package/lib/inventory/agentFormulationParser.js +57 -4
  68. package/lib/inventory/aiCollector.js +504 -10
  69. package/lib/inventory/aiInventory.js +12 -0
  70. package/lib/inventory/analyzer.js +488 -30
  71. package/lib/inventory/astWorker.js +6 -0
  72. package/lib/inventory/auditCategories.js +17 -0
  73. package/lib/inventory/cbomutils.js +30 -0
  74. package/lib/inventory/cdxrs.js +7 -2
  75. package/lib/inventory/communityAiConfigParser.js +215 -2
  76. package/lib/inventory/depsUtils.js +72 -0
  77. package/lib/inventory/dosai.js +508 -22
  78. package/lib/inventory/envcontext.js +215 -24
  79. package/lib/inventory/evidenceUtils.js +13 -0
  80. package/lib/inventory/fetchBatch.js +131 -2
  81. package/lib/inventory/fetchRate.js +74 -11
  82. package/lib/inventory/forgeEnricher.js +3 -2
  83. package/lib/inventory/jvmToolEnv.js +594 -0
  84. package/lib/inventory/mcpConfigParser.js +166 -2
  85. package/lib/inventory/mcpDiscovery.js +11 -0
  86. package/lib/inventory/mcpServerManifest.js +389 -0
  87. package/lib/inventory/mcpbBundle.js +419 -0
  88. package/lib/inventory/osinfo.js +1 -0
  89. package/lib/inventory/plugins.js +3 -0
  90. package/lib/inventory/protobom.js +54 -88
  91. package/lib/inventory/protobomLoader.js +4 -0
  92. package/lib/inventory/rustFormulationParser.js +1 -1
  93. package/lib/inventory/source.js +10 -4
  94. package/lib/inventory/stylePackageRefs.js +41 -0
  95. package/lib/inventory/svelteUtils.js +524 -0
  96. package/lib/inventory/toolRequirements.js +903 -0
  97. package/lib/managers/binary.js +24 -0
  98. package/lib/managers/docker.js +26 -5
  99. package/lib/parsers/huggingfaceUtils.js +15 -7
  100. package/lib/parsers/onnx.js +294 -0
  101. package/lib/parsers/safetensors.js +114 -0
  102. package/lib/server/server.js +1 -1
  103. package/lib/stages/postgen/annotationBuilder.js +97 -0
  104. package/lib/stages/postgen/auditBom.js +76 -79
  105. package/lib/stages/postgen/introspection/annotate.js +320 -0
  106. package/lib/stages/postgen/introspection/commandFacts.js +293 -0
  107. package/lib/stages/postgen/introspection/emit.js +192 -0
  108. package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
  109. package/lib/stages/postgen/introspection/markers.js +250 -0
  110. package/lib/stages/postgen/introspection/reflect.js +1214 -0
  111. package/lib/stages/postgen/introspection/report.js +1208 -0
  112. package/lib/stages/postgen/introspection/score.js +1067 -0
  113. package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
  114. package/lib/stages/postgen/parentEdge.js +156 -0
  115. package/lib/stages/postgen/postgen.js +284 -3
  116. package/lib/stages/postgen/ruleEngine.js +17 -0
  117. package/lib/stages/pregen/envAudit.js +42 -2
  118. package/lib/stages/pregen/pregen.js +1171 -6
  119. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +462 -0
  120. package/lib/validator/bomValidator.js +11 -2
  121. package/package.json +26 -26
  122. package/types/lib/audit/index.d.ts.map +1 -1
  123. package/types/lib/audit/scoring.d.ts +2 -8
  124. package/types/lib/audit/scoring.d.ts.map +1 -1
  125. package/types/lib/audit/targets.d.ts.map +1 -1
  126. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  127. package/types/lib/cli/cliOptions.d.ts +2 -1
  128. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  129. package/types/lib/cli/index.d.ts +10 -2
  130. package/types/lib/cli/index.d.ts.map +1 -1
  131. package/types/lib/cli/jsBom.d.ts +23 -0
  132. package/types/lib/cli/jsBom.d.ts.map +1 -1
  133. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  134. package/types/lib/cli/managedBom.d.ts +33 -0
  135. package/types/lib/cli/managedBom.d.ts.map +1 -1
  136. package/types/lib/cli/nativeBom.d.ts +88 -8
  137. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  138. package/types/lib/core/activity.d.ts.map +1 -1
  139. package/types/lib/core/buildLedger.d.ts +272 -0
  140. package/types/lib/core/buildLedger.d.ts.map +1 -0
  141. package/types/lib/core/deferredExit.d.ts +84 -0
  142. package/types/lib/core/deferredExit.d.ts.map +1 -0
  143. package/types/lib/core/env.d.ts +43 -1
  144. package/types/lib/core/env.d.ts.map +1 -1
  145. package/types/lib/core/fs.d.ts +21 -0
  146. package/types/lib/core/fs.d.ts.map +1 -1
  147. package/types/lib/core/logger.d.ts +2 -11
  148. package/types/lib/core/logger.d.ts.map +1 -1
  149. package/types/lib/core/severity.d.ts +11 -0
  150. package/types/lib/core/severity.d.ts.map +1 -0
  151. package/types/lib/core/syncFileWriter.d.ts +33 -0
  152. package/types/lib/core/syncFileWriter.d.ts.map +1 -0
  153. package/types/lib/core/userAgent.d.ts +56 -0
  154. package/types/lib/core/userAgent.d.ts.map +1 -0
  155. package/types/lib/ecosystems/cargoCache.d.ts +75 -0
  156. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -0
  157. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  158. package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
  159. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  160. package/types/lib/ecosystems/ecosystems.d.ts +15 -1
  161. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  162. package/types/lib/ecosystems/gradleutils.d.ts +53 -0
  163. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  164. package/types/lib/ecosystems/kosi.d.ts +73 -0
  165. package/types/lib/ecosystems/kosi.d.ts.map +1 -0
  166. package/types/lib/ecosystems/parsers-crystal.d.ts +27 -0
  167. package/types/lib/ecosystems/parsers-crystal.d.ts.map +1 -0
  168. package/types/lib/ecosystems/parsers-dotnet.d.ts +19 -0
  169. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  170. package/types/lib/ecosystems/parsers-elm.d.ts +79 -0
  171. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -0
  172. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  173. package/types/lib/ecosystems/parsers-julia.d.ts +57 -0
  174. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -0
  175. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  176. package/types/lib/ecosystems/parsers-lua.d.ts +50 -0
  177. package/types/lib/ecosystems/parsers-lua.d.ts.map +1 -0
  178. package/types/lib/ecosystems/parsers-misc.d.ts +23 -0
  179. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  180. package/types/lib/ecosystems/parsers-nim.d.ts +43 -0
  181. package/types/lib/ecosystems/parsers-nim.d.ts.map +1 -0
  182. package/types/lib/ecosystems/parsers-opam.d.ts +31 -0
  183. package/types/lib/ecosystems/parsers-opam.d.ts.map +1 -0
  184. package/types/lib/ecosystems/parsers-perl.d.ts +36 -0
  185. package/types/lib/ecosystems/parsers-perl.d.ts.map +1 -0
  186. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  187. package/types/lib/ecosystems/parsers-r.d.ts +35 -0
  188. package/types/lib/ecosystems/parsers-r.d.ts.map +1 -0
  189. package/types/lib/ecosystems/parsers-rebar.d.ts +42 -0
  190. package/types/lib/ecosystems/parsers-rebar.d.ts.map +1 -0
  191. package/types/lib/ecosystems/parsers-rust.d.ts +199 -0
  192. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  193. package/types/lib/ecosystems/parsers-spack.d.ts +33 -0
  194. package/types/lib/ecosystems/parsers-spack.d.ts.map +1 -0
  195. package/types/lib/ecosystems/parsers-stack.d.ts +35 -0
  196. package/types/lib/ecosystems/parsers-stack.d.ts.map +1 -0
  197. package/types/lib/ecosystems/parsers-terraform.d.ts +34 -0
  198. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -0
  199. package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
  200. package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
  201. package/types/lib/ecosystems/pythonutils.d.ts +21 -1
  202. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  203. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  204. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  205. package/types/lib/evinser/evinser.d.ts +34 -1
  206. package/types/lib/evinser/evinser.d.ts.map +1 -1
  207. package/types/lib/helpers/agenticToolutils.d.ts +87 -0
  208. package/types/lib/helpers/agenticToolutils.d.ts.map +1 -0
  209. package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
  210. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  211. package/types/lib/inventory/agentFormulationParser.d.ts.map +1 -1
  212. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  213. package/types/lib/inventory/aiInventory.d.ts.map +1 -1
  214. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  215. package/types/lib/inventory/auditCategories.d.ts +12 -0
  216. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  217. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  218. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  219. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  220. package/types/lib/inventory/dosai.d.ts +43 -0
  221. package/types/lib/inventory/dosai.d.ts.map +1 -1
  222. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  223. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  224. package/types/lib/inventory/fetchBatch.d.ts +43 -0
  225. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  226. package/types/lib/inventory/fetchRate.d.ts +9 -6
  227. package/types/lib/inventory/fetchRate.d.ts.map +1 -1
  228. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  229. package/types/lib/inventory/jvmToolEnv.d.ts +190 -0
  230. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
  231. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  232. package/types/lib/inventory/mcpDiscovery.d.ts.map +1 -1
  233. package/types/lib/inventory/mcpServerManifest.d.ts +19 -0
  234. package/types/lib/inventory/mcpServerManifest.d.ts.map +1 -0
  235. package/types/lib/inventory/mcpbBundle.d.ts +19 -0
  236. package/types/lib/inventory/mcpbBundle.d.ts.map +1 -0
  237. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  238. package/types/lib/inventory/plugins.d.ts.map +1 -1
  239. package/types/lib/inventory/protobom.d.ts +18 -13
  240. package/types/lib/inventory/protobom.d.ts.map +1 -1
  241. package/types/lib/inventory/protobomLoader.d.ts.map +1 -1
  242. package/types/lib/inventory/source.d.ts.map +1 -1
  243. package/types/lib/inventory/stylePackageRefs.d.ts +17 -0
  244. package/types/lib/inventory/stylePackageRefs.d.ts.map +1 -0
  245. package/types/lib/inventory/svelteUtils.d.ts +124 -0
  246. package/types/lib/inventory/svelteUtils.d.ts.map +1 -0
  247. package/types/lib/inventory/toolRequirements.d.ts +216 -0
  248. package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
  249. package/types/lib/managers/binary.d.ts.map +1 -1
  250. package/types/lib/managers/docker.d.ts.map +1 -1
  251. package/types/lib/parsers/huggingfaceUtils.d.ts +8 -1
  252. package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
  253. package/types/lib/parsers/onnx.d.ts +28 -0
  254. package/types/lib/parsers/onnx.d.ts.map +1 -0
  255. package/types/lib/parsers/safetensors.d.ts +22 -0
  256. package/types/lib/parsers/safetensors.d.ts.map +1 -0
  257. package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
  258. package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
  259. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  260. package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
  261. package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
  262. package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
  263. package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
  264. package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
  265. package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
  266. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
  267. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
  268. package/types/lib/stages/postgen/introspection/markers.d.ts +58 -0
  269. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
  270. package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
  271. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
  272. package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
  273. package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
  274. package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
  275. package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
  276. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
  277. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
  278. package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
  279. package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
  280. package/types/lib/stages/postgen/postgen.d.ts +35 -2
  281. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  282. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  283. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  284. package/types/lib/stages/pregen/pregen.d.ts +131 -0
  285. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  286. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,799 @@
1
+ /**
2
+ * Build ledger: an opt-in, worker-safe recorder of structured build-adequacy
3
+ * events.
4
+ *
5
+ * Producers record what a run did to its build environment — the tool versions
6
+ * a project expected, the tools that were actually found, the commands that
7
+ * failed, the fallback code paths that were taken — and the post-generation
8
+ * reflection turns those events into a fidelity verdict. The recorder follows
9
+ * the shape of `activity.js` but stays a separate stream: activity records
10
+ * describe security-relevant side effects consumed by the secure-mode
11
+ * machinery, while ledger events describe environment adequacy consumed by the
12
+ * introspection report.
13
+ *
14
+ * Two facts about workers shape the design:
15
+ *
16
+ * 1. A worker thread gets its own instance of this module, so events recorded
17
+ * inside a worker are invisible to the main thread's in-memory buffer.
18
+ * When `CDXGEN_INTROSPECT_LEDGER` is set, every instance appends its events
19
+ * to the same JSONL sidecar through an `O_APPEND` descriptor (only the main
20
+ * thread truncates, at open), which makes the sidecar the only complete
21
+ * record of a multi-worker run. Consumers must therefore prefer the sidecar
22
+ * when it exists and fall back to `getLedgerEvents()` only when it does not.
23
+ * 2. Every write reaches the descriptor synchronously at record time, so a
24
+ * lost tail is impossible; the descriptor is released when the process
25
+ * exits (`closeLogStreams` closes every shared writer) or earlier through
26
+ * {@link closeLedger}.
27
+ *
28
+ * Values that could carry credentials are scrubbed before they are stored or
29
+ * written: `command`, `path`, `detail`, `causeDetail` and `outputExcerpt` pass
30
+ * through sensitive-assignment redaction plus the BOM property sanitizer, so a
31
+ * leaked token can never reach a report file that agents upload.
32
+ */
33
+
34
+ import { readFileSync } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { join } from "node:path";
37
+
38
+ import {
39
+ isSensitiveEnvironmentVariableName,
40
+ readEnvironmentVariable,
41
+ } from "./activity.js";
42
+ import { safeReadFileSync } from "./fs.js";
43
+ import { thoughtLog } from "./logger.js";
44
+ import { dirNameStr } from "./paths.js";
45
+ import { sanitizeBomPropertyValue } from "./propertySanitizer.js";
46
+ import { openSyncFileWriter } from "./syncFileWriter.js";
47
+
48
+ /** Longest command-output excerpt an event keeps, taken from the tail: build
49
+ * tools print the banner first and the error last. */
50
+ const OUTPUT_EXCERPT_LIMIT = 2000;
51
+
52
+ /** Slots an excerpt-bearing event charges against the in-memory event cap. */
53
+ const EXCERPT_EVENT_WEIGHT = 2;
54
+
55
+ /**
56
+ * A structured build-adequacy event.
57
+ *
58
+ * @typedef {Object} LedgerEvent
59
+ * @property {string} kind One of LEDGER_EVENT_KINDS.
60
+ * @property {string} ecosystem Canonical project type ("java", "npm", "python", …).
61
+ * @property {string} [tool] Tool identity ("maven", "gradle", "swift", "dotnet").
62
+ * @property {string} [wanted] Version or version range the project asked for.
63
+ * @property {string} [found] Version actually resolved, or undefined when absent.
64
+ * @property {string} [source] How `found` was determined ("PATH", "wrapper", "sdkman", "env").
65
+ * @property {string} [path] Absolute path of the resolved tool or the file that was read.
66
+ * @property {string} [command] Command line, already redacted.
67
+ * @property {number} [exitCode]
68
+ * @property {string} [detail] One sentence, present tense, no remediation text.
69
+ * @property {string} [causeDetail] Short diagnosed cause of a failure ("gradle cannot run on Java 21"), when the producer derived one.
70
+ * @property {string} [outputExcerpt] Redacted, at most the last 2000 characters of the failed command's combined stdout and stderr, with interior newlines preserved. Suppressed entirely by `CDXGEN_INTROSPECT_NO_OUTPUT`.
71
+ * @property {string} [remediationId] Key into the remediation catalog (Deliverable 06).
72
+ * @property {string} [impact] One of LEDGER_EVENT_IMPACTS.
73
+ * @property {string} timestamp ISO 8601.
74
+ */
75
+
76
+ /**
77
+ * The closed set of event kinds. `kind` is validated against this object: a
78
+ * kind that is not listed is dropped (with a think-log note) rather than
79
+ * recorded, so a producer bug can never fail a customer's SBOM run. Adding a
80
+ * kind here is a schema change; consumers must tolerate unknown kinds by
81
+ * ignoring them.
82
+ *
83
+ * @type {Readonly<Record<string, string>>}
84
+ */
85
+ export const LEDGER_EVENT_KINDS = Object.freeze({
86
+ TOOL_EXPECTED: "tool.expected",
87
+ TOOL_RESOLVED: "tool.resolved",
88
+ TOOL_MISSING: "tool.missing",
89
+ TOOL_MISMATCH: "tool.mismatch",
90
+ COMMAND_ATTEMPTED: "command.attempted",
91
+ COMMAND_FAILED: "command.failed",
92
+ FALLBACK_ENGAGED: "fallback.engaged",
93
+ EVIDENCE_DEGRADED: "evidence.degraded",
94
+ LIFECYCLE_CLAIMED: "lifecycle.claimed",
95
+ });
96
+
97
+ const KNOWN_LEDGER_EVENT_KINDS = new Set(Object.values(LEDGER_EVENT_KINDS));
98
+
99
+ /**
100
+ * The closed set of `impact` values, describing what a degraded outcome costs
101
+ * the fidelity of the generated BOM.
102
+ *
103
+ * @type {Readonly<Record<string, string>>}
104
+ */
105
+ export const LEDGER_EVENT_IMPACTS = Object.freeze({
106
+ TRANSITIVE_DEPS: "transitive-deps",
107
+ VERSIONS: "versions",
108
+ INTEGRITY: "integrity",
109
+ LICENSES: "licenses",
110
+ COMPONENTS: "components",
111
+ NONE: "none",
112
+ });
113
+
114
+ /**
115
+ * Reserved `ecosystem` value for events that describe the recorder itself
116
+ * rather than a project ecosystem, such as the truncation marker. Consumers
117
+ * that group events by ecosystem must treat it as a global observation instead
118
+ * of creating an ecosystem entry for it.
119
+ *
120
+ * @type {string}
121
+ */
122
+ export const LEDGER_TOOL_ECOSYSTEM = "cdxgen";
123
+
124
+ /** Default cap on the in-memory event buffer. */
125
+ const DEFAULT_MAX_LEDGER_EVENTS = 10000;
126
+ /** Smallest usable cap: below this the keep-first-half/keep-last-half split degenerates. */
127
+ const MIN_MAX_LEDGER_EVENTS = 10;
128
+ /** Largest accepted cap; a larger value would be a memory hazard rather than a configuration. */
129
+ const MAX_MAX_LEDGER_EVENTS = 1000000;
130
+
131
+ /**
132
+ * Module-load snapshot of the build-introspection environment: `CDXGEN_INTROSPECT`
133
+ * is `true`/`1`, or `CDXGEN_INTROSPECT_LEDGER` names a sidecar file. Library
134
+ * callers that configure introspection before importing this module can read
135
+ * it directly; the CLI enables introspection after argument parsing, so every
136
+ * internal gate reads {@link isLedgerEnabled}, which resolves the same
137
+ * environment on each call.
138
+ *
139
+ * @type {boolean}
140
+ */
141
+ export const LEDGER_ENABLED =
142
+ ["true", "1"].includes(readEnvironmentVariable("CDXGEN_INTROSPECT")) ||
143
+ Boolean(readEnvironmentVariable("CDXGEN_INTROSPECT_LEDGER"));
144
+
145
+ /**
146
+ * True when build-introspection recording is enabled for this run: the same
147
+ * environment as {@link LEDGER_ENABLED}, read live. The CLI turns
148
+ * introspection on after argument parsing by setting `CDXGEN_INTROSPECT`, so
149
+ * a module-load snapshot would miss every event recorded after that point;
150
+ * the environment only ever flips in that one direction during a run.
151
+ *
152
+ * @returns {boolean} True when events should be recorded.
153
+ */
154
+ /**
155
+ * Whether build introspection is enabled for this run. The flag decides when
156
+ * it was given; otherwise the environment speaks, which is how a run that
157
+ * enabled introspection through a profile reaches the recorders.
158
+ *
159
+ * @param {Object} [options] CLI options.
160
+ * @returns {boolean} True when the run records and reports a verdict.
161
+ */
162
+ export function isIntrospectionEnabled(options) {
163
+ if (typeof options?.introspect === "boolean") {
164
+ return options.introspect;
165
+ }
166
+ return ["true", "1"].includes(readEnvironmentVariable("CDXGEN_INTROSPECT"));
167
+ }
168
+
169
+ export function isLedgerEnabled() {
170
+ return (
171
+ ["true", "1"].includes(readEnvironmentVariable("CDXGEN_INTROSPECT")) ||
172
+ Boolean(readEnvironmentVariable("CDXGEN_INTROSPECT_LEDGER"))
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Resolve the in-memory event cap from `CDXGEN_INTROSPECT_MAX_EVENTS`.
178
+ * Non-numeric values fall back to the default; the accepted range is clamped.
179
+ *
180
+ * @param {string|undefined} rawValue Raw environment variable value.
181
+ * @returns {number} Effective event cap.
182
+ */
183
+ function resolveMaxLedgerEvents(rawValue) {
184
+ if (rawValue === undefined || rawValue === "") {
185
+ return DEFAULT_MAX_LEDGER_EVENTS;
186
+ }
187
+ const parsed = Number.parseInt(rawValue, 10);
188
+ if (Number.isNaN(parsed)) {
189
+ return DEFAULT_MAX_LEDGER_EVENTS;
190
+ }
191
+ return Math.min(
192
+ Math.max(parsed, MIN_MAX_LEDGER_EVENTS),
193
+ MAX_MAX_LEDGER_EVENTS,
194
+ );
195
+ }
196
+
197
+ const MAX_LEDGER_EVENTS = resolveMaxLedgerEvents(
198
+ readEnvironmentVariable("CDXGEN_INTROSPECT_MAX_EVENTS"),
199
+ );
200
+ /** Number of leading events never evicted once truncation begins. */
201
+ const LEDGER_HEAD_KEEP = Math.floor(MAX_LEDGER_EVENTS / 2);
202
+
203
+ // The sidecar writer opens lazily on the first recorded event rather than at
204
+ // module load, because the CLI can name a sidecar (its own automatic temp
205
+ // sidecar or the user's) only after argument parsing. The first open still
206
+ // truncates once on the main thread, and every worker instance appends to the
207
+ // same path. A path that cannot be opened leaves the writer null and the run
208
+ // in-memory-only; JSONL lines are never allowed to spill into the diagnostic
209
+ // stream.
210
+ const LEDGER_WRITER_UNRESOLVED = Symbol("unresolved");
211
+ let ledgerFileWriter = LEDGER_WRITER_UNRESOLVED;
212
+
213
+ /**
214
+ * Resolve (once) and return the sidecar writer for this thread, or null when
215
+ * no sidecar is configured or the path could not be opened.
216
+ *
217
+ * @returns {Writable|null} The sidecar writer, or null.
218
+ */
219
+ function resolveLedgerWriter() {
220
+ if (ledgerFileWriter === LEDGER_WRITER_UNRESOLVED) {
221
+ const ledgerFilePath = readEnvironmentVariable("CDXGEN_INTROSPECT_LEDGER");
222
+ ledgerFileWriter = ledgerFilePath
223
+ ? openSyncFileWriter(ledgerFilePath, null)
224
+ : null;
225
+ }
226
+ return ledgerFileWriter || null;
227
+ }
228
+
229
+ const ledgerEvents = [];
230
+ let truncationRecorded = false;
231
+ let ledgerFileClosed = false;
232
+
233
+ /**
234
+ * Credential-name fragments that make a compound name sensitive even when no
235
+ * underscore separates the parts (`PGPASSWORD`, `registryToken`).
236
+ *
237
+ * @type {RegExp}
238
+ */
239
+ const COMPOUND_CREDENTIAL_NAME_PATTERN =
240
+ /(?:token|secret|pass(?:word)?|passwd|credential)/i;
241
+
242
+ /** Short flags whose value is a credential in build-tool command lines. */
243
+ const SENSITIVE_SHORT_FLAGS = new Set(["-p"]);
244
+
245
+ /**
246
+ * Test whether an assignment name (the part before `=` in a command argument)
247
+ * looks like a credential carrier. Leading flag dashes are stripped, hyphens
248
+ * are normalised to underscores for the existing sensitive-name pattern, a
249
+ * leading JVM-style property letter (`-Dpassword=…`) is also tried, and a
250
+ * compound name whose parts run together (`PGPASSWORD=…`) is caught by the
251
+ * fragment pattern.
252
+ *
253
+ * @param {string} name Assignment name as it appeared in the command.
254
+ * @returns {boolean} True when the name matches the sensitive-name pattern.
255
+ */
256
+ function isSensitiveAssignmentName(name) {
257
+ const normalisedName = name.replace(/^-+/, "").replaceAll("-", "_");
258
+ return (
259
+ isSensitiveEnvironmentVariableName(normalisedName) ||
260
+ isSensitiveEnvironmentVariableName(normalisedName.slice(1)) ||
261
+ COMPOUND_CREDENTIAL_NAME_PATTERN.test(normalisedName)
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Replace the value of every credential-bearing argument with a `[redacted]`
267
+ * marker, preserving all other arguments and whitespace. Both spellings are
268
+ * covered: the assignment form (`--password=x`, `-Dpassword=x`, `PGPASSWORD=x`)
269
+ * redacts within the token itself, and the space-separated flag form
270
+ * (`--password x`, `-p x`, `--registry-token x`) redacts the token that
271
+ * follows the flag.
272
+ *
273
+ * The assignment branch keeps everything after `=` as the value, so a value
274
+ * that runs into trailing punctuation (`FOO=bar)`) loses the punctuation with
275
+ * the value; that is the pre-existing shape of this redactor and is not
276
+ * widened here.
277
+ *
278
+ * @param {string} text Command line or path to scrub.
279
+ * @returns {string} Text with sensitive assignment and flag values replaced.
280
+ */
281
+ function redactSensitiveAssignments(text) {
282
+ const pieces = text.split(/(\s+)/);
283
+ for (let index = 0; index < pieces.length; index += 2) {
284
+ const piece = pieces[index];
285
+ const separator = piece.indexOf("=");
286
+ if (separator > 0) {
287
+ if (isSensitiveAssignmentName(piece.slice(0, separator))) {
288
+ pieces[index] = `${piece.slice(0, separator)}=[redacted]`;
289
+ }
290
+ continue;
291
+ }
292
+ if (
293
+ (piece.startsWith("--") && isSensitiveAssignmentName(piece)) ||
294
+ SENSITIVE_SHORT_FLAGS.has(piece)
295
+ ) {
296
+ const valueIndex = index + 2;
297
+ if (pieces[valueIndex] !== undefined) {
298
+ pieces[valueIndex] = "[redacted]";
299
+ }
300
+ }
301
+ }
302
+ return pieces.join("");
303
+ }
304
+
305
+ /**
306
+ * Credential-bearing headers whose value is a credential, echoed by verbose
307
+ * HTTP clients and CI logs. The value runs to the end of the line because a
308
+ * scheme word (`Basic`, `Bearer`) and the token behind it are one value. The
309
+ * header name survives so the output stays diagnosable.
310
+ *
311
+ * @type {RegExp}
312
+ */
313
+ const AUTHORIZATION_HEADER_PATTERN =
314
+ /((?:x-)?(?:proxy-)?authorization|cookie)(\s*:\s*)(("[^"]*"|'[^']*')|[^\r\n]*)/giu;
315
+
316
+ /**
317
+ * A value of any length behind a strong credential name and an unambiguous
318
+ * `:` or `=` separator. A bare name followed only by whitespace stays out of
319
+ * this pattern: prose like "the password is unknown" would otherwise lose
320
+ * words without losing the secret behind them.
321
+ *
322
+ * @type {RegExp}
323
+ */
324
+ const CREDENTIAL_NAMED_VALUE_PATTERN =
325
+ /((?:token|secret|pass(?:word)?|passwd|passphrase|credential)[a-z0-9_-]*\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/giu;
326
+
327
+ /**
328
+ * A token-shaped run of 32 or more characters beside a credential name, with
329
+ * `:`, `=` or whitespace as the separator. The length floor keeps checksums,
330
+ * digests and base64 artifacts that are not beside a credential name intact.
331
+ *
332
+ * @type {RegExp}
333
+ */
334
+ const CREDENTIAL_ADJACENT_VALUE_PATTERN =
335
+ /((?:api[-_]?key|access[-_]?key|secret[-_]?key|key|token|secret|pass(?:word)?|passwd|credential)[a-z0-9_-]*(?:\s*[=:]\s*|\s+))("[^"\s]{32,}"|'[^'\s]{32,}'|[a-z0-9._~+/=-]{32,})/giu;
336
+
337
+ /** Placeholder replacing the user's home directory in free-text fields. */
338
+ const HOME_DIR_PLACEHOLDER = "~";
339
+
340
+ /** The user's home directory, resolved once; empty when it names no usable directory. */
341
+ let cachedHomeDir;
342
+
343
+ /**
344
+ * The user's home directory, resolved once, or an empty string when the
345
+ * platform answers with a root-equivalent path that must not be rewritten.
346
+ *
347
+ * @returns {string} Home directory path, or an empty string.
348
+ */
349
+ function homeDirPath() {
350
+ if (cachedHomeDir === undefined) {
351
+ let home = "";
352
+ try {
353
+ home = homedir() || "";
354
+ } catch {
355
+ home = "";
356
+ }
357
+ cachedHomeDir = home === "/" || home === "\\" ? "" : home;
358
+ }
359
+ return cachedHomeDir;
360
+ }
361
+
362
+ /**
363
+ * Replace every occurrence of the user's home directory with `~`, so build
364
+ * output that names absolute paths under it stops identifying the machine.
365
+ *
366
+ * @param {string} text Free text of untrusted origin.
367
+ * @returns {string} Text with the home directory path rewritten.
368
+ */
369
+ function redactHomeDirectory(text) {
370
+ const home = homeDirPath();
371
+ if (!home || !text.includes(home)) {
372
+ return text;
373
+ }
374
+ return text.split(home).join(HOME_DIR_PLACEHOLDER);
375
+ }
376
+
377
+ /**
378
+ * Scrub a free-text ledger field: home paths first, sensitive assignment and
379
+ * flag values next, credential-bearing headers and adjacent token runs, then
380
+ * the BOM property sanitizer for URL userinfo/query strings and known
381
+ * credential shapes. This is the one redactor every introspection free-text
382
+ * field goes through, in the ledger and in both reports.
383
+ *
384
+ * @param {string} fieldName Ledger field being redacted.
385
+ * @param {string} value Raw field value.
386
+ * @returns {string} Redacted field value.
387
+ */
388
+ function redactLedgerText(fieldName, value) {
389
+ // The BOM property sanitizer runs first: it strips URL userinfo and query
390
+ // strings, so the assignment redactor below never meets a query parameter
391
+ // and cannot leave a redaction marker inside a URL.
392
+ let redacted = sanitizeBomPropertyValue(
393
+ fieldName,
394
+ redactHomeDirectory(String(value)),
395
+ );
396
+ redacted = redactSensitiveAssignments(redacted);
397
+ redacted = redacted.replace(
398
+ AUTHORIZATION_HEADER_PATTERN,
399
+ (_match, header, separator) => `${header}${separator}[redacted]`,
400
+ );
401
+ redacted = redacted.replace(
402
+ CREDENTIAL_NAMED_VALUE_PATTERN,
403
+ (_match, prefix) => `${prefix}[redacted]`,
404
+ );
405
+ redacted = redacted.replace(
406
+ CREDENTIAL_ADJACENT_VALUE_PATTERN,
407
+ (_match, prefix) => `${prefix}[redacted]`,
408
+ );
409
+ return redacted;
410
+ }
411
+
412
+ /**
413
+ * Redact a free-text introspection value with the ledger's own redactor. Both
414
+ * report renderers run their free text through this function so a field can
415
+ * never be redacted differently in the ledger, the JSON report and the
416
+ * markdown report.
417
+ *
418
+ * @param {string|undefined} value Free text of untrusted origin.
419
+ * @returns {string|undefined} Redacted text.
420
+ */
421
+ export function redactIntrospectionText(value) {
422
+ if (value === undefined || value === null) {
423
+ return value;
424
+ }
425
+ return redactLedgerText("detail", `${value}`);
426
+ }
427
+
428
+ /**
429
+ * Combined stdout and stderr of a spawned command, as the raw material of an
430
+ * output excerpt. Producers that already hold a spawn result call this so the
431
+ * recorder, not the producer, owns truncation and redaction.
432
+ *
433
+ * @param {Object|undefined} spawnResult A spawnSync-style result.
434
+ * @returns {string|undefined} The combined output, or undefined when the command produced none.
435
+ */
436
+ export function commandOutputText(spawnResult) {
437
+ if (!spawnResult) {
438
+ return undefined;
439
+ }
440
+ const stdout = spawnResult.stdout ? spawnResult.stdout.toString() : "";
441
+ const stderr = spawnResult.stderr ? spawnResult.stderr.toString() : "";
442
+ const combined = stdout || stderr ? `${stdout}\n${stderr}` : "";
443
+ return combined.trim() ? combined : undefined;
444
+ }
445
+
446
+ /**
447
+ * The tail of a command output, bounded to the excerpt limit. Interior
448
+ * newlines are the signal a reader diagnoses from, so only the edges are
449
+ * trimmed.
450
+ *
451
+ * @param {string} rawOutput Combined command output.
452
+ * @returns {string} At most the last OUTPUT_EXCERPT_LIMIT characters.
453
+ */
454
+ function tailExcerpt(rawOutput) {
455
+ const trimmed = rawOutput.trim();
456
+ return trimmed.length > OUTPUT_EXCERPT_LIMIT
457
+ ? trimmed.slice(-OUTPUT_EXCERPT_LIMIT)
458
+ : trimmed;
459
+ }
460
+
461
+ /**
462
+ * Whether another excerpt-bearing event fits inside the event cap. Such an
463
+ * event charges a second slot, so a run that fills its cap with excerpts
464
+ * starts dropping excerpts while every event still lands — a truncated
465
+ * ledger poisons the whole verdict, a missing excerpt degrades one entry.
466
+ *
467
+ * @returns {boolean} True when the excerpt may be stored.
468
+ */
469
+ function hasExcerptHeadroom() {
470
+ return ledgerEvents.length + EXCERPT_EVENT_WEIGHT <= MAX_LEDGER_EVENTS;
471
+ }
472
+
473
+ /**
474
+ * Whether the run opted out of carrying tool output at all. `true`/`1` match
475
+ * the CDXGEN_INTROSPECT convention; read live so a run can set the variable
476
+ * after the modules load.
477
+ *
478
+ * @returns {boolean} True when output excerpts must not be recorded.
479
+ */
480
+ function outputSuppressed() {
481
+ return ["true", "1"].includes(
482
+ readEnvironmentVariable("CDXGEN_INTROSPECT_NO_OUTPUT"),
483
+ );
484
+ }
485
+
486
+ /**
487
+ * Assemble a stored event from a kind and the producer's fields. Optional
488
+ * fields are kept in schema order and omitted when absent; every stored value
489
+ * is a plain string or number so the JSONL sidecar stays schema-stable.
490
+ *
491
+ * @param {string} kind Validated event kind.
492
+ * @param {Partial<LedgerEvent>} fields Producer-supplied fields.
493
+ * @returns {LedgerEvent} Event ready to store and append to the sidecar.
494
+ */
495
+ function buildLedgerEvent(kind, fields) {
496
+ const event = /** @type {LedgerEvent} */ ({ kind });
497
+ event.ecosystem = fields.ecosystem;
498
+ if (fields.tool) {
499
+ event.tool = String(fields.tool);
500
+ }
501
+ if (fields.wanted) {
502
+ event.wanted = String(fields.wanted);
503
+ }
504
+ if (fields.found) {
505
+ event.found = String(fields.found);
506
+ }
507
+ if (fields.source) {
508
+ event.source = String(fields.source);
509
+ }
510
+ if (fields.path) {
511
+ event.path = redactLedgerText("path", fields.path);
512
+ }
513
+ if (fields.command) {
514
+ event.command = redactLedgerText("command", fields.command);
515
+ }
516
+ if (typeof fields.exitCode === "number") {
517
+ event.exitCode = fields.exitCode;
518
+ }
519
+ if (fields.detail) {
520
+ event.detail = redactLedgerText("detail", fields.detail);
521
+ }
522
+ if (fields.causeDetail) {
523
+ event.causeDetail = redactLedgerText("causeDetail", fields.causeDetail);
524
+ }
525
+ if (
526
+ fields.outputExcerpt &&
527
+ `${fields.outputExcerpt}`.trim() &&
528
+ !outputSuppressed() &&
529
+ hasExcerptHeadroom()
530
+ ) {
531
+ event.outputExcerpt = redactLedgerText(
532
+ "outputExcerpt",
533
+ tailExcerpt(`${fields.outputExcerpt}`),
534
+ );
535
+ }
536
+ if (fields.remediationId) {
537
+ event.remediationId = String(fields.remediationId);
538
+ }
539
+ if (fields.impact) {
540
+ event.impact = String(fields.impact);
541
+ }
542
+ event.timestamp = new Date().toISOString();
543
+ return event;
544
+ }
545
+
546
+ /**
547
+ * Append one event to the JSONL sidecar. Failures are swallowed: the sidecar
548
+ * is an observation channel and must never abort BOM generation, and the
549
+ * in-memory buffer still holds the event.
550
+ *
551
+ * @param {LedgerEvent} event Event to append.
552
+ * @returns {void}
553
+ */
554
+ function persistLedgerEvent(event) {
555
+ const writer = resolveLedgerWriter();
556
+ if (!writer || ledgerFileClosed) {
557
+ return;
558
+ }
559
+ try {
560
+ writer.write(`${JSON.stringify(event)}\n`);
561
+ } catch {
562
+ // The sidecar is best-effort; the in-memory buffer keeps the event.
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Store an event in the in-memory buffer and persist it. Once the buffer
568
+ * reaches its cap, the first half stays pinned, a single truncation marker is
569
+ * inserted after it, and the tail slides forward one event at a time; the
570
+ * sidecar, when enabled, keeps every event.
571
+ *
572
+ * @param {LedgerEvent} event Event to store.
573
+ * @returns {LedgerEvent} The stored event.
574
+ */
575
+ function appendLedgerEvent(event) {
576
+ if (ledgerEvents.length >= MAX_LEDGER_EVENTS) {
577
+ if (!truncationRecorded) {
578
+ truncationRecorded = true;
579
+ // The marker describes the recorder, not the ecosystem whose event
580
+ // happened to reach the cap, so it carries the reserved tool ecosystem.
581
+ const marker = buildLedgerEvent(LEDGER_EVENT_KINDS.EVIDENCE_DEGRADED, {
582
+ ecosystem: LEDGER_TOOL_ECOSYSTEM,
583
+ detail: `The in-memory build ledger reached its cap of ${MAX_LEDGER_EVENTS} events and now keeps only the earliest ${LEDGER_HEAD_KEEP} and the most recent events; the JSONL sidecar, when enabled, retains every event.`,
584
+ });
585
+ ledgerEvents.splice(LEDGER_HEAD_KEEP, 0, marker);
586
+ persistLedgerEvent(marker);
587
+ }
588
+ ledgerEvents.splice(
589
+ LEDGER_HEAD_KEEP + 1,
590
+ ledgerEvents.length + 1 - MAX_LEDGER_EVENTS,
591
+ );
592
+ }
593
+ ledgerEvents.push(event);
594
+ persistLedgerEvent(event);
595
+ return event;
596
+ }
597
+
598
+ /**
599
+ * Record one build-adequacy event. Returns immediately after a single boolean
600
+ * test when introspection is disabled, so producers may pass plain values
601
+ * without gating the call themselves.
602
+ *
603
+ * Events with a kind outside LEDGER_EVENT_KINDS, or without a non-empty
604
+ * `ecosystem`, are dropped with a think-log note instead of throwing: the
605
+ * ledger is instrumentation, and a producer bug must never fail an SBOM run.
606
+ *
607
+ * @param {string} kind Event kind; one of LEDGER_EVENT_KINDS.
608
+ * @param {Partial<LedgerEvent>} [fields] Event fields; `command`, `path`, `detail`, `causeDetail` and `outputExcerpt` are redacted before storage, and `outputExcerpt` is additionally bounded to its tail.
609
+ * @returns {LedgerEvent|undefined} The stored event, or undefined when recording is disabled or the event was dropped.
610
+ */
611
+ export function recordLedgerEvent(kind, fields = {}) {
612
+ if (!isLedgerEnabled()) {
613
+ return undefined;
614
+ }
615
+ if (!KNOWN_LEDGER_EVENT_KINDS.has(kind)) {
616
+ thoughtLog(
617
+ `Dropping a build ledger event with unknown kind ${kind}; expected one of ${Object.values(LEDGER_EVENT_KINDS).join(", ")}.`,
618
+ );
619
+ return undefined;
620
+ }
621
+ if (typeof fields?.ecosystem !== "string" || !fields.ecosystem) {
622
+ thoughtLog(
623
+ `Dropping a ${kind} build ledger event without a non-empty ecosystem.`,
624
+ );
625
+ return undefined;
626
+ }
627
+ return appendLedgerEvent(buildLedgerEvent(kind, fields));
628
+ }
629
+
630
+ /**
631
+ * Remediation catalog loaded from `data/remediations.json`, mapping every
632
+ * known `remediationId` to its ecosystem, default event kind and default
633
+ * impact. The ids are a public contract matched by agents, so the catalog file
634
+ * and the ids referenced by producers must stay in lock-step; a poku test
635
+ * asserts the two sets are equal.
636
+ *
637
+ * Loaded lazily on the first {@link recordDegradation} call so a disabled run
638
+ * pays no file read and a missing or unreadable file degrades to a null
639
+ * catalog instead of failing module load.
640
+ *
641
+ * @type {Record<string, {ecosystem: string, kind: string, impact: string, title: string}>|null|undefined}
642
+ */
643
+ let remediationCatalog;
644
+
645
+ /**
646
+ * Load (once) the remediation catalog from `data/remediations.json`.
647
+ *
648
+ * @returns {Record<string, {ecosystem: string, kind: string, impact: string, title: string}>|null} Catalog keyed by id, or null when it cannot be read.
649
+ */
650
+ function loadRemediationCatalog() {
651
+ if (remediationCatalog !== undefined) {
652
+ return remediationCatalog;
653
+ }
654
+ try {
655
+ remediationCatalog = JSON.parse(
656
+ readFileSync(join(dirNameStr, "data", "remediations.json"), "utf-8"),
657
+ );
658
+ } catch {
659
+ // The catalog is a data file shipped with cdxgen; its absence means ids
660
+ // cannot be resolved and degradation events would carry no kind.
661
+ remediationCatalog = null;
662
+ }
663
+ return remediationCatalog;
664
+ }
665
+
666
+ /**
667
+ * Resolve the event kind for a remediation id from the catalog.
668
+ *
669
+ * @param {string} remediationId Remediation id to look up.
670
+ * @returns {string|undefined} Default event kind, or undefined when the id is unknown or the catalog is unavailable.
671
+ */
672
+ export function remediationKindFor(remediationId) {
673
+ return loadRemediationCatalog()?.[remediationId]?.kind;
674
+ }
675
+
676
+ /**
677
+ * The full remediation catalog, for consumers that need the extended entry
678
+ * fields (target tier, actions, verify clause) rather than a single resolved
679
+ * kind. Returns the cached parsed catalog; null when the data file is
680
+ * missing or unreadable.
681
+ *
682
+ * @returns {Record<string, Object>|null} Catalog keyed by remediation id, or null when unavailable.
683
+ */
684
+ export function getRemediationCatalog() {
685
+ return loadRemediationCatalog();
686
+ }
687
+
688
+ /**
689
+ * Record a degraded outcome together with the remediation that would restore
690
+ * the lost evidence. The kind and the remediationId come from the catalog
691
+ * entry for `remediationId`, so a site only supplies the facts it observed;
692
+ * `ecosystem` and `impact` are required from the caller because only the site
693
+ * knows which scanned ecosystem paid the cost and how much. A site whose
694
+ * degradation differs in kind from the catalog default (a command that threw
695
+ * rather than a fallback that engaged) may pass an explicit `kind` in fields.
696
+ *
697
+ * Events for unknown ids are dropped with a think-log note: an id outside the
698
+ * catalog is a producer bug and the catalog is the contract.
699
+ *
700
+ * @param {string} remediationId Key into data/remediations.json.
701
+ * @param {Partial<LedgerEvent> & {kind?: string}} fields Event fields; `ecosystem` and `impact` are required, `kind` overrides the catalog default.
702
+ * @returns {LedgerEvent|undefined} The stored event, or undefined when recording is disabled or the event was dropped.
703
+ */
704
+ export function recordDegradation(remediationId, fields = {}) {
705
+ if (!isLedgerEnabled()) {
706
+ return undefined;
707
+ }
708
+ const kind = fields.kind || remediationKindFor(remediationId);
709
+ if (!kind) {
710
+ thoughtLog(
711
+ `Dropping a degradation event for unknown remediationId ${remediationId}; add it to data/remediations.json.`,
712
+ );
713
+ return undefined;
714
+ }
715
+ const { kind: _kindOverride, ...rest } = fields;
716
+ return recordLedgerEvent(kind, {
717
+ ...rest,
718
+ remediationId,
719
+ });
720
+ }
721
+
722
+ /**
723
+ * Snapshot of the events recorded by this thread. Each event and the array
724
+ * itself are frozen, so a consumer cannot mutate the recorder's state.
725
+ * In a multi-worker run this buffer is this thread's view only; the JSONL
726
+ * sidecar is the complete record.
727
+ *
728
+ * @returns {LedgerEvent[]} Frozen array of frozen events.
729
+ */
730
+ export function getLedgerEvents() {
731
+ return Object.freeze(
732
+ ledgerEvents.map((event) => Object.freeze({ ...event })),
733
+ );
734
+ }
735
+
736
+ /**
737
+ * Clear the in-memory buffer and the truncation state. Intended for tests; a
738
+ * production run never resets its own ledger. The sidecar file is append-only
739
+ * and is not touched.
740
+ *
741
+ * @returns {void}
742
+ */
743
+ export function resetLedgerEvents() {
744
+ ledgerEvents.length = 0;
745
+ truncationRecorded = false;
746
+ }
747
+
748
+ /**
749
+ * Parse a JSONL sidecar produced by `CDXGEN_INTROSPECT_LEDGER` back into
750
+ * events. Blank lines and lines that fail to parse — a torn final line from an
751
+ * interrupted run is expected — are skipped. Prefer this over the in-memory
752
+ * buffer whenever the sidecar exists: worker threads append to the same file,
753
+ * so it is the only complete record of a multi-worker run.
754
+ *
755
+ * @param {string} ledgerPath Path to the JSONL sidecar.
756
+ * @returns {LedgerEvent[]} Frozen array of frozen events, empty when the file is missing or unreadable.
757
+ */
758
+ export function loadLedgerFile(ledgerPath) {
759
+ const content = safeReadFileSync(ledgerPath, "utf-8");
760
+ if (typeof content !== "string" || !content.length) {
761
+ return Object.freeze([]);
762
+ }
763
+ const events = [];
764
+ for (const line of content.split("\n")) {
765
+ const trimmedLine = line.trim();
766
+ if (!trimmedLine) {
767
+ continue;
768
+ }
769
+ let parsed;
770
+ try {
771
+ parsed = JSON.parse(trimmedLine);
772
+ } catch {
773
+ continue;
774
+ }
775
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
776
+ events.push(Object.freeze(parsed));
777
+ }
778
+ }
779
+ return Object.freeze(events);
780
+ }
781
+
782
+ /**
783
+ * Close the sidecar descriptor held by this thread. Idempotent; further
784
+ * events stay in memory only. The descriptor is also released when the
785
+ * process exits through `closeLogStreams`.
786
+ *
787
+ * @returns {void}
788
+ */
789
+ export function closeLedger() {
790
+ if (ledgerFileClosed) {
791
+ return;
792
+ }
793
+ ledgerFileClosed = true;
794
+ // A writer that was never resolved means no event reached the sidecar, so
795
+ // there is no descriptor to release and no file was created.
796
+ if (ledgerFileWriter !== LEDGER_WRITER_UNRESOLVED) {
797
+ ledgerFileWriter?.closeSync();
798
+ }
799
+ }