@cdxgen/cdxgen 13.0.1 → 13.1.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 (155) hide show
  1. package/README.md +26 -13
  2. package/bin/audit.js +6 -0
  3. package/bin/cdxgen.js +125 -4
  4. package/bin/evinse.js +1 -1
  5. package/data/component-tags.json +8 -1
  6. package/data/frameworks-list.json +86 -1
  7. package/data/remediations.json +1144 -0
  8. package/data/rules/asar-archives.yaml +1 -1
  9. package/data/rules/build-fidelity.yaml +782 -0
  10. package/data/rules/package-integrity.yaml +13 -13
  11. package/lib/audit/index.js +48 -4
  12. package/lib/audit/scoring.js +2 -8
  13. package/lib/cli/bomAssembly.js +20 -1
  14. package/lib/cli/cliOptions.js +94 -7
  15. package/lib/cli/index.js +76 -48
  16. package/lib/cli/jsBom.js +32 -3
  17. package/lib/cli/jvmBom.js +1494 -1315
  18. package/lib/cli/managedBom.js +97 -28
  19. package/lib/cli/nativeBom.js +189 -10
  20. package/lib/core/buildLedger.js +799 -0
  21. package/lib/core/deferredExit.js +141 -0
  22. package/lib/core/env.js +69 -1
  23. package/lib/core/fs.js +60 -0
  24. package/lib/core/logger.js +12 -99
  25. package/lib/core/severity.js +16 -0
  26. package/lib/core/syncFileWriter.js +106 -0
  27. package/lib/ecosystems/ecosystems.js +9 -0
  28. package/lib/ecosystems/gradleutils.js +355 -15
  29. package/lib/ecosystems/parsers-js.js +26 -0
  30. package/lib/ecosystems/parsers-jvm.js +9 -0
  31. package/lib/ecosystems/parsers-misc.js +16 -2
  32. package/lib/ecosystems/parsers-python.js +171 -35
  33. package/lib/ecosystems/parsers-rust.js +12 -2
  34. package/lib/ecosystems/pypiClassifiers.js +326 -0
  35. package/lib/ecosystems/pythonutils.js +49 -1
  36. package/lib/ecosystems/rubyutils.js +13 -0
  37. package/lib/evinser/evinser.js +30 -12
  38. package/lib/helpers/remote/dependency-track.js +81 -14
  39. package/lib/inventory/auditCategories.js +9 -0
  40. package/lib/inventory/depsUtils.js +72 -0
  41. package/lib/inventory/dosai.js +431 -22
  42. package/lib/inventory/envcontext.js +206 -18
  43. package/lib/inventory/fetchBatch.js +57 -0
  44. package/lib/inventory/jvmToolEnv.js +593 -0
  45. package/lib/inventory/osinfo.js +1 -0
  46. package/lib/inventory/toolRequirements.js +903 -0
  47. package/lib/managers/binary.js +24 -0
  48. package/lib/managers/docker.js +26 -5
  49. package/lib/server/server.js +1 -1
  50. package/lib/stages/postgen/annotationBuilder.js +97 -0
  51. package/lib/stages/postgen/auditBom.js +76 -79
  52. package/lib/stages/postgen/introspection/annotate.js +320 -0
  53. package/lib/stages/postgen/introspection/commandFacts.js +293 -0
  54. package/lib/stages/postgen/introspection/emit.js +192 -0
  55. package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
  56. package/lib/stages/postgen/introspection/markers.js +227 -0
  57. package/lib/stages/postgen/introspection/reflect.js +1214 -0
  58. package/lib/stages/postgen/introspection/report.js +1208 -0
  59. package/lib/stages/postgen/introspection/score.js +1067 -0
  60. package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
  61. package/lib/stages/postgen/parentEdge.js +156 -0
  62. package/lib/stages/postgen/postgen.js +115 -2
  63. package/lib/stages/postgen/ruleEngine.js +17 -0
  64. package/lib/stages/pregen/envAudit.js +42 -2
  65. package/lib/stages/pregen/pregen.js +1171 -6
  66. package/lib/validator/bomValidator.js +11 -2
  67. package/package.json +14 -14
  68. package/types/lib/audit/index.d.ts.map +1 -1
  69. package/types/lib/audit/scoring.d.ts +2 -8
  70. package/types/lib/audit/scoring.d.ts.map +1 -1
  71. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  72. package/types/lib/cli/cliOptions.d.ts +2 -1
  73. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  74. package/types/lib/cli/index.d.ts +8 -0
  75. package/types/lib/cli/index.d.ts.map +1 -1
  76. package/types/lib/cli/jsBom.d.ts.map +1 -1
  77. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  78. package/types/lib/cli/managedBom.d.ts.map +1 -1
  79. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  80. package/types/lib/core/buildLedger.d.ts +272 -0
  81. package/types/lib/core/buildLedger.d.ts.map +1 -0
  82. package/types/lib/core/deferredExit.d.ts +84 -0
  83. package/types/lib/core/deferredExit.d.ts.map +1 -0
  84. package/types/lib/core/env.d.ts +22 -1
  85. package/types/lib/core/env.d.ts.map +1 -1
  86. package/types/lib/core/fs.d.ts +21 -0
  87. package/types/lib/core/fs.d.ts.map +1 -1
  88. package/types/lib/core/logger.d.ts +2 -11
  89. package/types/lib/core/logger.d.ts.map +1 -1
  90. package/types/lib/core/severity.d.ts +11 -0
  91. package/types/lib/core/severity.d.ts.map +1 -0
  92. package/types/lib/core/syncFileWriter.d.ts +33 -0
  93. package/types/lib/core/syncFileWriter.d.ts.map +1 -0
  94. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  95. package/types/lib/ecosystems/gradleutils.d.ts +33 -0
  96. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  97. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  98. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  99. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  100. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  101. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  102. package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
  103. package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
  104. package/types/lib/ecosystems/pythonutils.d.ts +20 -0
  105. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  106. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  107. package/types/lib/evinser/evinser.d.ts +5 -1
  108. package/types/lib/evinser/evinser.d.ts.map +1 -1
  109. package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
  110. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  111. package/types/lib/inventory/auditCategories.d.ts +8 -0
  112. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  113. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  114. package/types/lib/inventory/dosai.d.ts +20 -0
  115. package/types/lib/inventory/dosai.d.ts.map +1 -1
  116. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  117. package/types/lib/inventory/fetchBatch.d.ts +9 -0
  118. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  119. package/types/lib/inventory/jvmToolEnv.d.ts +189 -0
  120. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
  121. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  122. package/types/lib/inventory/toolRequirements.d.ts +216 -0
  123. package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
  124. package/types/lib/managers/binary.d.ts.map +1 -1
  125. package/types/lib/managers/docker.d.ts.map +1 -1
  126. package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
  127. package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
  128. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  129. package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
  130. package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
  131. package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
  132. package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
  133. package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
  134. package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
  135. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
  136. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
  137. package/types/lib/stages/postgen/introspection/markers.d.ts +57 -0
  138. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
  139. package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
  140. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
  141. package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
  142. package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
  143. package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
  144. package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
  145. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
  146. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
  147. package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
  148. package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
  149. package/types/lib/stages/postgen/postgen.d.ts +2 -2
  150. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  151. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  152. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  153. package/types/lib/stages/pregen/pregen.d.ts +131 -0
  154. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  155. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,903 @@
1
+ import { join } from "node:path";
2
+
3
+ import {
4
+ isDryRun,
5
+ isSecureMode,
6
+ readEnvironmentVariable,
7
+ } from "../core/activity.js";
8
+ import { isDeno } from "../core/env.js";
9
+ import { safeExistsSync, safeReadFileSync } from "../core/fs.js";
10
+
11
+ /**
12
+ * Upper bound on the content read from a single tool-requirements file, so a
13
+ * corrupt or hostile file cannot force unbounded reads.
14
+ *
15
+ * @type {number}
16
+ */
17
+ const MAX_REQUIREMENTS_FILE_BYTES = 256 * 1024;
18
+
19
+ /**
20
+ * Maps a declared build-tool name to the cdxgen ecosystem whose events it
21
+ * belongs to. Tools outside the map are attributed to the reserved
22
+ * "generic" ecosystem so `.tool-versions` entries for arbitrary tools
23
+ * (terraform, ghc, …) stay recordable without inventing ecosystems.
24
+ *
25
+ * @type {Object<string, string>}
26
+ */
27
+ const TOOL_ECOSYSTEMS = {
28
+ bazel: "java",
29
+ cargo: "rust",
30
+ cc: "c",
31
+ clang: "c",
32
+ cmake: "c",
33
+ conda: "python",
34
+ dart: "dart",
35
+ deno: "npm",
36
+ dotnet: "csharp",
37
+ flutter: "dart",
38
+ gem: "ruby",
39
+ go: "go",
40
+ golang: "go",
41
+ gradle: "java",
42
+ java: "java",
43
+ javac: "java",
44
+ mix: "elixir",
45
+ mvn: "java",
46
+ maven: "java",
47
+ node: "npm",
48
+ nodejs: "npm",
49
+ npm: "npm",
50
+ pnpm: "npm",
51
+ poetry: "python",
52
+ python: "python",
53
+ python3: "python",
54
+ pip: "python",
55
+ pip3: "python",
56
+ rbenv: "ruby",
57
+ ruby: "ruby",
58
+ rust: "rust",
59
+ rustc: "rust",
60
+ rustup: "rust",
61
+ sbt: "java",
62
+ scala: "java",
63
+ swift: "swift",
64
+ uv: "python",
65
+ yarn: "npm",
66
+ bundler: "ruby",
67
+ bun: "npm",
68
+ };
69
+
70
+ /**
71
+ * Resolve the ecosystem a declared tool belongs to.
72
+ *
73
+ * @param {string} tool Declared tool name
74
+ * @returns {string} cdxgen ecosystem name, or "generic" for unknown tools.
75
+ */
76
+ export function ecosystemForTool(tool) {
77
+ return TOOL_ECOSYSTEMS[`${tool}`.toLowerCase()] || "generic";
78
+ }
79
+
80
+ /**
81
+ * Check whether a version identifier carries a prerelease suffix such as
82
+ * `-rc-3`, `-M2`, or `-milestone-1`. The marker list is exact so that vendor
83
+ * suffixes that merely begin with the same letters, such as the `-crac` java
84
+ * distributions, stay classified as stable.
85
+ *
86
+ * @param {string} version Version identifier
87
+ * @returns {boolean} True for prerelease versions.
88
+ */
89
+ export function isPrereleaseVersion(version) {
90
+ if (typeof version !== "string") {
91
+ return false;
92
+ }
93
+ const separatorIndex = version.indexOf("-");
94
+ if (separatorIndex < 0) {
95
+ return false;
96
+ }
97
+ return /^(rc-?\d*|m\d+|milestone|alpha|beta|ea)\b/i.test(
98
+ version.slice(separatorIndex + 1),
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Compare two dotted version identifiers. Numeric components are compared
104
+ * numerically, stable releases rank above prereleases, and prerelease
105
+ * suffixes are compared token-wise so `rc-9` beats `rc-10` only when
106
+ * numerically larger. Missing components count as zero, so `21` equals
107
+ * `21.0.0`.
108
+ *
109
+ * @param {string} a First version
110
+ * @param {string} b Second version
111
+ * @returns {number} Negative when a < b, positive when a > b, zero on equality.
112
+ */
113
+ export function compareVersions(a, b) {
114
+ const aCore = `${a}`.split("-")[0];
115
+ const bCore = `${b}`.split("-")[0];
116
+ const aComponents = aCore.split(".");
117
+ const bComponents = bCore.split(".");
118
+ const maxComponents = Math.max(aComponents.length, bComponents.length);
119
+ for (let i = 0; i < maxComponents; i++) {
120
+ const aPart = Number.parseInt(aComponents[i] ?? "0", 10) || 0;
121
+ const bPart = Number.parseInt(bComponents[i] ?? "0", 10) || 0;
122
+ if (aPart !== bPart) {
123
+ return aPart - bPart;
124
+ }
125
+ }
126
+ const aPre = isPrereleaseVersion(`${a}`);
127
+ const bPre = isPrereleaseVersion(`${b}`);
128
+ if (aPre !== bPre) {
129
+ return aPre ? -1 : 1;
130
+ }
131
+ if (!aPre) {
132
+ return 0;
133
+ }
134
+ const aTokens = `${a}`.slice(aCore.length + 1).split(/[.-]/);
135
+ const bTokens = `${b}`.slice(bCore.length + 1).split(/[.-]/);
136
+ const maxTokens = Math.max(aTokens.length, bTokens.length);
137
+ for (let i = 0; i < maxTokens; i++) {
138
+ const aToken = aTokens[i] ?? "";
139
+ const bToken = bTokens[i] ?? "";
140
+ const aNum = Number.parseInt(aToken, 10);
141
+ const bNum = Number.parseInt(bToken, 10);
142
+ let comparison;
143
+ if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) {
144
+ comparison = aNum - bNum;
145
+ } else {
146
+ comparison = aToken.localeCompare(bToken);
147
+ }
148
+ if (comparison !== 0) {
149
+ return comparison;
150
+ }
151
+ }
152
+ return aTokens.length - bTokens.length;
153
+ }
154
+
155
+ /**
156
+ * Extract the JDK major from a `java --version` style description such as
157
+ * `openjdk 21.0.11 2025-04-15` or a bare sdkman java identifier such as
158
+ * `25-tem`. General availability releases print an undotted major
159
+ * (`openjdk 25 2025-09-16`), so the first token starting with a digit is
160
+ * used rather than requiring a dotted version.
161
+ *
162
+ * @param {string} versionDesc Version description string
163
+ * @returns {number|undefined} JDK major version.
164
+ */
165
+ export function extractJavaMajor(versionDesc) {
166
+ if (typeof versionDesc !== "string") {
167
+ return undefined;
168
+ }
169
+ for (const rawToken of versionDesc.trim().split(/\s+/)) {
170
+ // `java -version` quotes the identifier, e.g. openjdk version "17.0.9"
171
+ const token = rawToken.replaceAll('"', "");
172
+ const match = token.match(/^(\d+)(?:[.\-+_].*)?$/);
173
+ if (!match) {
174
+ continue;
175
+ }
176
+ const major = Number.parseInt(match[1], 10);
177
+ if (Number.isNaN(major)) {
178
+ continue;
179
+ }
180
+ // Legacy identifiers such as 1.8.0_452 name the major after the leading 1.
181
+ if (major === 1) {
182
+ const legacyMatch = token.match(/^1\.(\d+)/);
183
+ return legacyMatch ? Number.parseInt(legacyMatch[1], 10) : undefined;
184
+ }
185
+ return major;
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ /**
191
+ * Extract the first version-looking token from a tool version description,
192
+ * such as `go version go1.23.1 darwin/arm64` or `rustc 1.82.0 (f6e511eec
193
+ * 2024-10-08)`. Leading letters (`v20.11.1`, `go1.23.1`) and quoting are
194
+ * stripped before validation.
195
+ *
196
+ * @param {string} versionDesc Raw version description
197
+ * @returns {string|undefined} The version token, when one is found.
198
+ */
199
+ export function extractVersionToken(versionDesc) {
200
+ if (typeof versionDesc !== "string") {
201
+ return undefined;
202
+ }
203
+ for (const rawToken of versionDesc.trim().split(/\s+/)) {
204
+ const token = rawToken.replaceAll('"', "").replace(/^[^0-9]+/, "");
205
+ if (!token || !/^\d/.test(token)) {
206
+ continue;
207
+ }
208
+ if (/^\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$/.test(token)) {
209
+ return token;
210
+ }
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ /**
216
+ * Verdict of a declared-requirement check. `unparseable` means the wanted or
217
+ * found value carries no comparable version, which is never evidence of a
218
+ * defect: an unparseable requirement must not produce a mismatch.
219
+ *
220
+ * @typedef {"satisfied"|"violated"|"unparseable"} RequirementVerdict
221
+ */
222
+
223
+ /**
224
+ * Check whether a found version satisfies a declared requirement. Wanted
225
+ * forms handled: exact versions (`3.9.9`), major-only and partial pins
226
+ * (`21`, `20.11`), `.x` ranges (`20.x`), caret and tilde ranges (`^20.11`,
227
+ * `~4.9`), comparison ranges (`>=18`, `<21`), hyphen ranges (`1.9 - 2.3`),
228
+ * whitespace-conjoined ranges (`>=18 <=20`), `||` alternatives, and the
229
+ * unconstrained `*`/`latest`. Anything else is unparseable and never
230
+ * violates.
231
+ *
232
+ * @param {string} found Version actually present, possibly embedded in a
233
+ * longer description such as `go version go1.23.1 darwin/arm64`.
234
+ * @param {string} wanted Declared requirement.
235
+ * @returns {RequirementVerdict} Verdict of the check.
236
+ */
237
+ export function checkVersionRequirement(found, wanted) {
238
+ const wantedText = `${wanted ?? ""}`.trim();
239
+ if (!wantedText || ["*", "x", "latest"].includes(wantedText.toLowerCase())) {
240
+ return "satisfied";
241
+ }
242
+ const foundToken = extractVersionToken(found);
243
+ if (!foundToken) {
244
+ return "unparseable";
245
+ }
246
+ let anyUnparseable = false;
247
+ for (const alternative of wantedText.split("||")) {
248
+ const verdict = checkAlternativeRequirement(foundToken, alternative.trim());
249
+ if (verdict === "satisfied") {
250
+ return "satisfied";
251
+ }
252
+ if (verdict === "unparseable") {
253
+ anyUnparseable = true;
254
+ }
255
+ }
256
+ return anyUnparseable ? "unparseable" : "violated";
257
+ }
258
+
259
+ /**
260
+ * Check one `||` alternative, which may itself conjoin several whitespace
261
+ * separated constraints or a hyphen range.
262
+ *
263
+ * @param {string} foundToken Extracted found version token
264
+ * @param {string} alternative One alternative of the wanted expression
265
+ * @returns {RequirementVerdict} Verdict of the check.
266
+ */
267
+ function checkAlternativeRequirement(foundToken, alternative) {
268
+ if (!alternative) {
269
+ return "satisfied";
270
+ }
271
+ const hyphenRange = alternative.match(/^(\S+)\s+-\s+(\S+)$/);
272
+ if (hyphenRange) {
273
+ const lower = extractVersionToken(hyphenRange[1]);
274
+ const upper = extractVersionToken(hyphenRange[2]);
275
+ if (!lower || !upper) {
276
+ return "unparseable";
277
+ }
278
+ return compareVersions(foundToken, lower) >= 0 &&
279
+ compareVersions(foundToken, upper) <= 0
280
+ ? "satisfied"
281
+ : "violated";
282
+ }
283
+ let unparseable = false;
284
+ for (const constraint of alternative.split(/\s+/).filter(Boolean)) {
285
+ const verdict = checkSingleConstraint(foundToken, constraint);
286
+ if (verdict === "violated") {
287
+ return "violated";
288
+ }
289
+ if (verdict === "unparseable") {
290
+ unparseable = true;
291
+ }
292
+ }
293
+ return unparseable ? "unparseable" : "satisfied";
294
+ }
295
+
296
+ /**
297
+ * Check one constraint token against a found version token.
298
+ *
299
+ * @param {string} foundToken Extracted found version token
300
+ * @param {string} constraint A single constraint such as `>=18` or `^20.11`
301
+ * @returns {RequirementVerdict} Verdict of the check.
302
+ */
303
+ function checkSingleConstraint(foundToken, constraint) {
304
+ const operatorMatch = constraint.match(/^(>=|<=|==|=|>|<)\s*(.+)$/);
305
+ if (operatorMatch) {
306
+ const bound = extractVersionToken(operatorMatch[2]);
307
+ if (!bound) {
308
+ return "unparseable";
309
+ }
310
+ const comparison = compareVersions(foundToken, bound);
311
+ const satisfied = {
312
+ ">=": comparison >= 0,
313
+ ">": comparison > 0,
314
+ "<=": comparison <= 0,
315
+ "<": comparison < 0,
316
+ "=": comparison === 0,
317
+ "==": comparison === 0,
318
+ }[operatorMatch[1]];
319
+ return satisfied ? "satisfied" : "violated";
320
+ }
321
+ if (constraint.startsWith("^") || constraint.startsWith("~")) {
322
+ const bound = extractVersionToken(constraint.slice(1));
323
+ if (!bound) {
324
+ return "unparseable";
325
+ }
326
+ const core = bound.split("-")[0].split(".");
327
+ const major = Number.parseInt(core[0], 10) || 0;
328
+ const minor = Number.parseInt(core[1], 10) || 0;
329
+ let upper;
330
+ if (constraint.startsWith("^")) {
331
+ upper =
332
+ major > 0
333
+ ? `${major + 1}.0.0`
334
+ : minor > 0
335
+ ? `0.${minor + 1}.0`
336
+ : `0.0.${Number.parseInt(core[2], 10) ? Number.parseInt(core[2], 10) + 1 : 1}`;
337
+ } else {
338
+ upper = `${major}.${minor + 1}.0`;
339
+ }
340
+ return compareVersions(foundToken, bound) >= 0 &&
341
+ compareVersions(foundToken, upper) < 0
342
+ ? "satisfied"
343
+ : "violated";
344
+ }
345
+ // Trailing `.x`/`.*` segments lower the pin's precision: `20.x` is a
346
+ // major-line pin, `20.11.x` a minor-line pin.
347
+ let cleaned = constraint;
348
+ while (/\.(x|X|\*)$/.test(cleaned)) {
349
+ cleaned = cleaned.slice(0, cleaned.lastIndexOf("."));
350
+ }
351
+ const core = cleaned.split("-")[0];
352
+ const parts = core.split(".");
353
+ if (!/^\d+$/.test(parts[0] || "")) {
354
+ return "unparseable";
355
+ }
356
+ if (parts.length === 1) {
357
+ // A bare major names the major line rather than an exact release.
358
+ return Number.parseInt(foundToken, 10) === Number.parseInt(parts[0], 10)
359
+ ? "satisfied"
360
+ : "violated";
361
+ }
362
+ if (parts.length === 2) {
363
+ const foundMajor = extractMajorMinor(foundToken);
364
+ if (!foundMajor) {
365
+ return "unparseable";
366
+ }
367
+ return foundMajor.major === Number.parseInt(parts[0], 10) &&
368
+ foundMajor.minor === Number.parseInt(parts[1], 10)
369
+ ? "satisfied"
370
+ : "violated";
371
+ }
372
+ return compareVersions(foundToken, cleaned) === 0 ? "satisfied" : "violated";
373
+ }
374
+
375
+ /**
376
+ * Extract the major and minor components of a version token.
377
+ *
378
+ * @param {string} versionToken Version token such as `20.11.1`
379
+ * @returns {Object|undefined} `{ major, minor }`, when both are present.
380
+ */
381
+ function extractMajorMinor(versionToken) {
382
+ const core = `${versionToken}`.split("-")[0].split(".");
383
+ if (!core[1]) {
384
+ return undefined;
385
+ }
386
+ const major = Number.parseInt(core[0], 10);
387
+ const minor = Number.parseInt(core[1], 10);
388
+ if (Number.isNaN(major) || Number.isNaN(minor)) {
389
+ return undefined;
390
+ }
391
+ return { major, minor };
392
+ }
393
+
394
+ /**
395
+ * Classify the result of a failed tool probe. A probe that was denied the
396
+ * right to run must be reported as `denied` — the tool may well be present —
397
+ * while only evidence that the command does not exist counts as `missing`.
398
+ *
399
+ * @param {Object|string|undefined} result Result object from spawnSync, or
400
+ * undefined when no result shape is known.
401
+ * @returns {"found"|"denied"|"missing"} Classification of the probe result.
402
+ */
403
+ export function classifyProbeResult(result) {
404
+ if (!result || typeof result !== "object") {
405
+ return "denied";
406
+ }
407
+ if (result.status === 0 && !result.error) {
408
+ return "found";
409
+ }
410
+ if (
411
+ result.pid === undefined &&
412
+ result.status === undefined &&
413
+ result.signal === undefined &&
414
+ result.stdout === undefined &&
415
+ result.stderr === undefined
416
+ ) {
417
+ // Deno's node:child_process returns an empty result when the runtime
418
+ // denies the spawn outright.
419
+ return "denied";
420
+ }
421
+ const error = result.error;
422
+ const errorCode = error?.code;
423
+ if (errorCode === "EACCES" || errorCode === "EPERM") {
424
+ return "denied";
425
+ }
426
+ const errorText =
427
+ typeof error === "string" ? error : `${error?.message || ""}`;
428
+ if (
429
+ error?.dryRun ||
430
+ errorCode === "CDXGEN_DRY_RUN" ||
431
+ /dry run|permission/i.test(errorText)
432
+ ) {
433
+ return "denied";
434
+ }
435
+ return "missing";
436
+ }
437
+
438
+ /**
439
+ * Classify the active environment restriction that would stop a command from
440
+ * running, without the human-readable wording. `describeSpawnRestriction`
441
+ * derives its message from this classifier, and consumers that need to act on
442
+ * the cause rather than the prose use it directly.
443
+ *
444
+ * @param {string} command Command that would run
445
+ * @returns {"dry-run"|"secure-mode"|"allowlist"|"deno"|undefined} Restriction cause, or undefined when nothing restricts the command.
446
+ */
447
+ export function classifySpawnRestriction(command) {
448
+ if (isDryRun) {
449
+ return "dry-run";
450
+ }
451
+ if (
452
+ isSecureMode &&
453
+ globalThis.process?.permission?.has &&
454
+ !globalThis.process.permission.has("child")
455
+ ) {
456
+ return "secure-mode";
457
+ }
458
+ const commandName = `${command}`.trim().split(" ")[0];
459
+ const allowlist = readEnvironmentVariable("CDXGEN_ALLOWED_COMMANDS");
460
+ if (
461
+ allowlist &&
462
+ !allowlist
463
+ .split(",")
464
+ .map((entry) => entry.trim())
465
+ .includes(commandName)
466
+ ) {
467
+ return "allowlist";
468
+ }
469
+ if (isDeno) {
470
+ const query = globalThis.Deno?.permissions?.querySync?.({
471
+ name: "run",
472
+ command: commandName,
473
+ });
474
+ if (query && query.state !== "granted") {
475
+ return "deno";
476
+ }
477
+ }
478
+ return undefined;
479
+ }
480
+
481
+ const SPAWN_RESTRICTION_DESCRIPTIONS = {
482
+ "dry-run": "dry-run mode blocks command execution",
483
+ "secure-mode": "secure mode denies child process execution",
484
+ allowlist: "the command is not present in CDXGEN_ALLOWED_COMMANDS",
485
+ deno: "the Deno run permission is not granted for this command",
486
+ };
487
+
488
+ /**
489
+ * Explain why a command probe may not run in the current environment, so a
490
+ * failed probe can be reported as degraded evidence rather than a missing
491
+ * tool.
492
+ *
493
+ * @param {string} command Command that was probed
494
+ * @returns {string|undefined} Human-readable restriction, or undefined when
495
+ * the environment does not restrict the command.
496
+ */
497
+ export function describeSpawnRestriction(command) {
498
+ const cause = classifySpawnRestriction(command);
499
+ return cause ? SPAWN_RESTRICTION_DESCRIPTIONS[cause] : undefined;
500
+ }
501
+
502
+ /**
503
+ * Parse the content of an asdf/mise `.tool-versions` file into the first
504
+ * version each tool declares. Comment lines and inline comments are ignored;
505
+ * a tool line listing several versions keeps the first.
506
+ *
507
+ * @param {string} content `.tool-versions` file content
508
+ * @returns {Object|undefined} Map of tool name to declared version.
509
+ */
510
+ export function parseToolVersionsFile(content) {
511
+ if (typeof content !== "string" || !content.trim()) {
512
+ return undefined;
513
+ }
514
+ const pins = {};
515
+ for (const rawLine of content.split(/\r?\n/)) {
516
+ const line = rawLine.replace(/#.*$/, "").trim();
517
+ if (!line) {
518
+ continue;
519
+ }
520
+ const pieces = line.split(/\s+/);
521
+ const tool = (pieces.shift() || "").toLowerCase();
522
+ const wanted = pieces.find((piece) => /^[\w.+-]+$/.test(piece));
523
+ if (!tool || !/^[a-z][a-z0-9-]*$/.test(tool) || !wanted) {
524
+ continue;
525
+ }
526
+ if (!(tool in pins)) {
527
+ pins[tool] = wanted;
528
+ }
529
+ }
530
+ return Object.keys(pins).length ? pins : undefined;
531
+ }
532
+
533
+ /**
534
+ * Parse the `go` and `toolchain` directives of a `go.mod` file. The
535
+ * `toolchain` directive wins when both are present because it names the
536
+ * exact toolchain the module asks for.
537
+ *
538
+ * @param {string} content `go.mod` file content
539
+ * @returns {Object|undefined} `{ tool, version, directive }` with
540
+ * `directive` naming the line the version came from.
541
+ */
542
+ export function parseGoModFile(content) {
543
+ if (typeof content !== "string" || !content.trim()) {
544
+ return undefined;
545
+ }
546
+ let goDirective;
547
+ let toolchainDirective;
548
+ for (const rawLine of content.split(/\r?\n/)) {
549
+ const line = rawLine.trim();
550
+ let match = line.match(/^toolchain\s+(\S+)$/);
551
+ if (match) {
552
+ toolchainDirective = match[1];
553
+ continue;
554
+ }
555
+ match = line.match(/^go\s+(\S+)$/);
556
+ if (match) {
557
+ goDirective = match[1];
558
+ }
559
+ }
560
+ const directive = toolchainDirective ? "toolchain" : "go";
561
+ const declared = toolchainDirective || goDirective;
562
+ if (!declared) {
563
+ return undefined;
564
+ }
565
+ // Toolchain identifiers spell the prefix out (`go1.24.1`); the directive
566
+ // line does not (`1.23.0`).
567
+ const version = declared.replace(/^go(?=\d)/, "");
568
+ if (!/^\d[\w.-]*$/.test(version)) {
569
+ return undefined;
570
+ }
571
+ return { tool: "go", version, directive };
572
+ }
573
+
574
+ /**
575
+ * Check whether a rust channel value looks plausible: a semver, a channel
576
+ * name such as `stable`, or a dated nightly.
577
+ *
578
+ * @param {string} channel Raw channel value
579
+ * @returns {boolean} True when the value is a plausible channel.
580
+ */
581
+ function isPlausibleRustChannel(channel) {
582
+ return /^[\w][\w.-]*$/.test(channel);
583
+ }
584
+
585
+ /**
586
+ * Parse a `rust-toolchain.toml` or plain `rust-toolchain` file into its
587
+ * channel. Target-specific subsections are ignored, so only the `[toolchain]`
588
+ * section's `channel` key is read.
589
+ *
590
+ * @param {string} content Toolchain file content
591
+ * @returns {Object|undefined} `{ channel }` when a channel is declared.
592
+ */
593
+ export function parseRustToolchainFile(content) {
594
+ if (typeof content !== "string") {
595
+ return undefined;
596
+ }
597
+ const lines = content
598
+ .split(/\r?\n/)
599
+ .map((line) => line.replace(/#.*$/, "").trim())
600
+ .filter(Boolean);
601
+ if (!lines.length) {
602
+ return undefined;
603
+ }
604
+ if (!content.includes("=")) {
605
+ // Plain toolchain files hold just the channel string.
606
+ return isPlausibleRustChannel(lines[0]) ? { channel: lines[0] } : undefined;
607
+ }
608
+ let channel;
609
+ let inToolchainSection = false;
610
+ for (const line of lines) {
611
+ if (line.startsWith("[")) {
612
+ inToolchainSection = line.replace(/^\[|\]$/g, "").trim() === "toolchain";
613
+ continue;
614
+ }
615
+ const separatorIndex = line.indexOf("=");
616
+ if (separatorIndex <= 0 || !inToolchainSection) {
617
+ continue;
618
+ }
619
+ if (line.slice(0, separatorIndex).trim() !== "channel") {
620
+ continue;
621
+ }
622
+ channel = line
623
+ .slice(separatorIndex + 1)
624
+ .trim()
625
+ .replace(/^["']|["']$/g, "");
626
+ }
627
+ return channel && isPlausibleRustChannel(channel) ? { channel } : undefined;
628
+ }
629
+
630
+ /**
631
+ * Parse a `package.json` content into the tool requirements it declares
632
+ * through `engines` and `packageManager`. The `packageManager` pin's
633
+ * integrity-hash suffix (`pnpm@9.1.0+sha512.…`) is stripped to the bare
634
+ * version.
635
+ *
636
+ * @param {string} content `package.json` content
637
+ * @returns {Object|undefined} Map of tool name to declared version.
638
+ */
639
+ export function parsePackageJsonToolRequirements(content) {
640
+ if (typeof content !== "string" || !content.trim()) {
641
+ return undefined;
642
+ }
643
+ let parsed;
644
+ try {
645
+ parsed = JSON.parse(content);
646
+ } catch {
647
+ return undefined;
648
+ }
649
+ if (!parsed || typeof parsed !== "object") {
650
+ return undefined;
651
+ }
652
+ const requirements = {};
653
+ const enginesNode = parsed.engines?.node;
654
+ if (typeof enginesNode === "string" && enginesNode.trim()) {
655
+ requirements.node = enginesNode.trim();
656
+ }
657
+ const packageManager = parsed.packageManager;
658
+ if (typeof packageManager === "string") {
659
+ const atSignIndex = packageManager.indexOf("@");
660
+ if (atSignIndex > 0) {
661
+ const name = packageManager.slice(0, atSignIndex).toLowerCase();
662
+ const version = packageManager.slice(atSignIndex + 1).split("+")[0];
663
+ if (/^[a-z][a-z0-9-]*$/.test(name) && /^[\w.+-]+$/.test(version)) {
664
+ requirements[name] = version;
665
+ }
666
+ }
667
+ }
668
+ return Object.keys(requirements).length ? requirements : undefined;
669
+ }
670
+
671
+ /**
672
+ * Parse a `global.json` file into the .NET SDK version it pins.
673
+ *
674
+ * @param {string} content `global.json` content
675
+ * @returns {Object|undefined} Map with the `dotnet` requirement.
676
+ */
677
+ export function parseGlobalJsonToolRequirements(content) {
678
+ if (typeof content !== "string" || !content.trim()) {
679
+ return undefined;
680
+ }
681
+ let parsed;
682
+ try {
683
+ parsed = JSON.parse(content);
684
+ } catch {
685
+ return undefined;
686
+ }
687
+ const version = parsed?.sdk?.version;
688
+ if (typeof version !== "string" || !/^[\w.+-]+$/.test(version.trim())) {
689
+ return undefined;
690
+ }
691
+ return { dotnet: version.trim() };
692
+ }
693
+
694
+ /**
695
+ * Parse the `requires-python` declaration of a `pyproject.toml` file.
696
+ *
697
+ * @param {string} content `pyproject.toml` content
698
+ * @returns {Object|undefined} Map with the `python` requirement.
699
+ */
700
+ export function parsePyprojectRequiresPython(content) {
701
+ if (typeof content !== "string" || !content.trim()) {
702
+ return undefined;
703
+ }
704
+ for (const rawLine of content.split(/\r?\n/)) {
705
+ const match = rawLine.match(/^\s*requires-python\s*=\s*["']([^"']+)["']/);
706
+ if (match) {
707
+ return { python: match[1].trim() };
708
+ }
709
+ }
710
+ return undefined;
711
+ }
712
+
713
+ /**
714
+ * Parse an `.nvmrc` file into the Node.js version it requests.
715
+ *
716
+ * @param {string} content `.nvmrc` content
717
+ * @returns {Object|undefined} Map with the `node` requirement.
718
+ */
719
+ export function parseNvmrc(content) {
720
+ if (typeof content !== "string") {
721
+ return undefined;
722
+ }
723
+ const value = content
724
+ .split(/\r?\n/)[0]
725
+ .replace(/#.*$/, "")
726
+ .trim()
727
+ .replace(/^v/, "");
728
+ return value ? { node: value } : undefined;
729
+ }
730
+
731
+ /**
732
+ * Parse a `.python-version` file into the interpreter version it requests.
733
+ *
734
+ * @param {string} content `.python-version` content
735
+ * @returns {Object|undefined} Map with the `python` requirement.
736
+ */
737
+ export function parsePythonVersionFile(content) {
738
+ if (typeof content !== "string") {
739
+ return undefined;
740
+ }
741
+ const value = content.split(/\r?\n/)[0].replace(/#.*$/, "").trim();
742
+ return value ? { python: value } : undefined;
743
+ }
744
+
745
+ /**
746
+ * Parse a `.java-version` file into the JDK version it pins. Both the bare
747
+ * major (`21`) and a full version (`21.0.5`) occur in the wild, and both are
748
+ * passed through verbatim — an install command needs the exact declaration.
749
+ *
750
+ * The legacy `1.N` spelling of a pre-9 release (`1.8`, `1.8.0_302`) is
751
+ * normalised to its modern major (`8`), because it is the major line that
752
+ * names a package: the literal declaration yields `Temurin.1.JDK`, which no
753
+ * provisioner offers.
754
+ *
755
+ * Distribution-prefixed values written by version managers (`temurin-21`,
756
+ * `openjdk64-17.0.1`) name a vendor build rather than a release, so they are
757
+ * left unparsed rather than turned into an install argument no provisioner
758
+ * accepts; the run then reports the version as unresolved and the agent asks.
759
+ *
760
+ * @param {string} content `.java-version` content
761
+ * @returns {Object|undefined} Map with the `java` requirement.
762
+ */
763
+ export function parseJavaVersionFile(content) {
764
+ if (typeof content !== "string") {
765
+ return undefined;
766
+ }
767
+ const value = content.split(/\r?\n/)[0].replace(/#.*$/, "").trim();
768
+ if (!/^\d[\w.+-]*$/.test(value)) {
769
+ return undefined;
770
+ }
771
+ const legacy = /^1\.(\d+)(?:[._-].*)?$/.exec(value);
772
+ return { java: legacy ? legacy[1] : value };
773
+ }
774
+
775
+ /**
776
+ * Parse the `BUNDLED WITH` section of a `Gemfile.lock` into the bundler
777
+ * version the lockfile was written with.
778
+ *
779
+ * @param {string} content `Gemfile.lock` content
780
+ * @returns {string|undefined} Bundler version, when declared.
781
+ */
782
+ export function parseGemfileLockBundlerVersion(content) {
783
+ if (typeof content !== "string" || !content.trim()) {
784
+ return undefined;
785
+ }
786
+ const lines = content.split(/\r?\n/);
787
+ for (let index = 0; index < lines.length; index++) {
788
+ if (lines[index].trim() !== "BUNDLED WITH") {
789
+ continue;
790
+ }
791
+ for (
792
+ let next = index + 1;
793
+ next < Math.min(index + 3, lines.length);
794
+ next++
795
+ ) {
796
+ const value = lines[next].trim();
797
+ if (!value) {
798
+ continue;
799
+ }
800
+ return /^\d[\w.-]*$/.test(value) ? value : undefined;
801
+ }
802
+ }
803
+ return undefined;
804
+ }
805
+
806
+ /**
807
+ * Read every declared tool requirement a project directory declares through
808
+ * its well-known tool pin files: `.tool-versions`, `.nvmrc`, `.java-version`,
809
+ * `.python-version`, `pyproject.toml`, `go.mod`, `rust-toolchain.toml`,
810
+ * `rust-toolchain`, `package.json`, and `global.json`. Missing files are
811
+ * skipped; each entry names the file that declared it so the report can
812
+ * trace every expectation back to its source.
813
+ *
814
+ * @param {string} projectPath Project directory to inspect
815
+ * @returns {Array<{tool: string, wanted: string, source: string, ecosystem: string, path: string}>}
816
+ * Declared requirements, empty when the directory declares none.
817
+ */
818
+ export function readDeclaredToolRequirements(projectPath) {
819
+ const requirements = [];
820
+ if (!projectPath) {
821
+ return requirements;
822
+ }
823
+ const addRequirements = (pins, fileName) => {
824
+ if (!pins) {
825
+ return;
826
+ }
827
+ const filePath = join(projectPath, fileName);
828
+ for (const [tool, wanted] of Object.entries(pins)) {
829
+ const trimmedWanted = `${wanted}`.trim();
830
+ if (!tool || !trimmedWanted) {
831
+ continue;
832
+ }
833
+ requirements.push({
834
+ tool,
835
+ wanted: trimmedWanted,
836
+ source: fileName,
837
+ ecosystem: ecosystemForTool(tool),
838
+ path: filePath,
839
+ });
840
+ }
841
+ };
842
+ const readRequirementFile = (fileName) => {
843
+ const filePath = join(projectPath, fileName);
844
+ if (!safeExistsSync(filePath)) {
845
+ return undefined;
846
+ }
847
+ const content = safeReadFileSync(filePath, "utf-8");
848
+ return typeof content === "string"
849
+ ? content.slice(0, MAX_REQUIREMENTS_FILE_BYTES)
850
+ : undefined;
851
+ };
852
+ const toolVersionsPins = parseToolVersionsFile(
853
+ readRequirementFile(".tool-versions"),
854
+ );
855
+ addRequirements(toolVersionsPins, ".tool-versions");
856
+ addRequirements(parseNvmrc(readRequirementFile(".nvmrc")), ".nvmrc");
857
+ addRequirements(
858
+ parseJavaVersionFile(readRequirementFile(".java-version")),
859
+ ".java-version",
860
+ );
861
+ addRequirements(
862
+ parsePythonVersionFile(readRequirementFile(".python-version")),
863
+ ".python-version",
864
+ );
865
+ const pyprojectPins = parsePyprojectRequiresPython(
866
+ readRequirementFile("pyproject.toml"),
867
+ );
868
+ if (pyprojectPins) {
869
+ addRequirements(
870
+ { python: pyprojectPins.python },
871
+ "pyproject.toml:requires-python",
872
+ );
873
+ }
874
+ const goMod = parseGoModFile(readRequirementFile("go.mod"));
875
+ if (goMod) {
876
+ addRequirements(
877
+ { [goMod.tool]: goMod.version },
878
+ `go.mod:${goMod.directive}`,
879
+ );
880
+ }
881
+ const rustToml = parseRustToolchainFile(
882
+ readRequirementFile("rust-toolchain.toml"),
883
+ );
884
+ if (rustToml) {
885
+ addRequirements({ rustc: rustToml.channel }, "rust-toolchain.toml");
886
+ } else {
887
+ const rustPlain = parseRustToolchainFile(
888
+ readRequirementFile("rust-toolchain"),
889
+ );
890
+ if (rustPlain) {
891
+ addRequirements({ rustc: rustPlain.channel }, "rust-toolchain");
892
+ }
893
+ }
894
+ addRequirements(
895
+ parsePackageJsonToolRequirements(readRequirementFile("package.json")),
896
+ "package.json",
897
+ );
898
+ addRequirements(
899
+ parseGlobalJsonToolRequirements(readRequirementFile("global.json")),
900
+ "global.json",
901
+ );
902
+ return requirements;
903
+ }