@cdxgen/cdxgen 13.6.0 → 13.7.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 (264) hide show
  1. package/README.md +15 -8
  2. package/bin/audit.js +7 -4
  3. package/bin/cdxgen.js +256 -37
  4. package/bin/convert.js +146 -22
  5. package/bin/evinse.js +25 -3
  6. package/bin/repl.js +47 -3
  7. package/bin/sign.js +211 -34
  8. package/bin/tracebom.js +18 -6
  9. package/bin/validate.js +18 -3
  10. package/bin/verify.js +113 -5
  11. package/data/behavior-taxonomy.schema.json +1491 -0
  12. package/data/cyclonedx-2.0-bundled.schema.json +18606 -5528
  13. package/data/cyclonedx-2.0-cryptography-defs.schema.json +857 -0
  14. package/data/pqc-migration.json +239 -0
  15. package/data/remediations.json +52 -0
  16. package/data/rules/cbom-security.yaml +4 -3
  17. package/data/rules/package-integrity.yaml +28 -0
  18. package/lib/audit/index.js +25 -3
  19. package/lib/cli/bomAssembly.js +80 -1
  20. package/lib/cli/cliOptions.js +121 -17
  21. package/lib/cli/index.js +609 -898
  22. package/lib/cli/jsBom.js +3 -0
  23. package/lib/cli/jvmBom.js +365 -40
  24. package/lib/cli/managedBom.js +19 -3
  25. package/lib/cli/nativeBom.js +10 -0
  26. package/lib/core/env.js +26 -0
  27. package/lib/core/fs.js +126 -3
  28. package/lib/core/globPatterns.js +218 -0
  29. package/lib/core/processTree.js +99 -12
  30. package/lib/core/projectConfig.js +332 -25
  31. package/lib/core/toolRegistry.js +15 -1
  32. package/lib/ecosystems/denoutils.js +2 -2
  33. package/lib/ecosystems/golem.js +3 -8
  34. package/lib/ecosystems/gradleutils.js +1082 -6
  35. package/lib/ecosystems/jsEvidence.js +90 -38
  36. package/lib/ecosystems/kosi.js +3 -5
  37. package/lib/ecosystems/npmVersionResolution.js +376 -0
  38. package/lib/ecosystems/parsers-python.js +89 -28
  39. package/lib/ecosystems/parsers-rust.js +117 -15
  40. package/lib/ecosystems/remote/huggingface.js +22 -0
  41. package/lib/ecosystems/rusi.js +2 -4
  42. package/lib/ecosystems/scalasem.js +2 -1
  43. package/lib/evinser/evinser.js +196 -34
  44. package/lib/evinser/scalasem.js +5 -1
  45. package/lib/helpers/agenticToolutils.js +112 -1
  46. package/lib/helpers/bomSigner.js +43 -9
  47. package/lib/helpers/jssSigner.js +1276 -0
  48. package/lib/inventory/agentBlueprint.js +193 -0
  49. package/lib/inventory/agentToolNames.js +145 -0
  50. package/lib/inventory/aiCollector.js +153 -7
  51. package/lib/inventory/aiInventory.js +44 -8
  52. package/lib/inventory/aiProviders.js +324 -0
  53. package/lib/inventory/bomUtils.js +8 -1
  54. package/lib/inventory/ciParsers/githubActionsLock.js +821 -0
  55. package/lib/inventory/citations.js +31 -2
  56. package/lib/inventory/communityAiConfigParser.js +978 -80
  57. package/lib/inventory/cyclonedx20.js +152 -0
  58. package/lib/inventory/cyclonedx20Common.js +286 -0
  59. package/lib/inventory/cyclonedx20Derived.js +159 -0
  60. package/lib/inventory/cyclonedx20Parties.js +552 -0
  61. package/lib/inventory/cyclonedx20Read.js +768 -0
  62. package/lib/inventory/cyclonedx20ReadCommon.js +374 -0
  63. package/lib/inventory/cyclonedx20ReadComponents.js +692 -0
  64. package/lib/inventory/cyclonedx20ReadContext.js +380 -0
  65. package/lib/inventory/cyclonedx20ReadEvidence.js +466 -0
  66. package/lib/inventory/cyclonedx20ReadSections.js +599 -0
  67. package/lib/inventory/cyclonedx20Render.js +766 -0
  68. package/lib/inventory/cyclonedx20RenderComponents.js +716 -0
  69. package/lib/inventory/cyclonedx20RenderDefinitions.js +405 -0
  70. package/lib/inventory/cyclonedx20RenderEvidence.js +726 -0
  71. package/lib/inventory/cyclonedx20RenderHardware.js +203 -0
  72. package/lib/inventory/cyclonedx20RenderPatents.js +174 -0
  73. package/lib/inventory/cyclonedx20RenderRefIds.js +314 -0
  74. package/lib/inventory/cyclonedx20RenderSections.js +499 -0
  75. package/lib/inventory/cyclonedx20Tables.js +594 -0
  76. package/lib/inventory/deps.js +106 -13
  77. package/lib/inventory/dosai.js +236 -1
  78. package/lib/inventory/dosaiParsers.js +3 -6
  79. package/lib/inventory/dotnetRuntime.js +164 -14
  80. package/lib/inventory/ecosystemFormulationParsers.js +979 -0
  81. package/lib/inventory/envcontext.js +132 -38
  82. package/lib/inventory/evidenceUtils.js +2 -1
  83. package/lib/inventory/formulationParsers.js +95 -13
  84. package/lib/inventory/hbom.js +45 -6
  85. package/lib/inventory/hbomLoader.js +166 -0
  86. package/lib/inventory/mcpConfigParser.js +5 -0
  87. package/lib/inventory/occurrenceKey.js +90 -0
  88. package/lib/inventory/pqcMigration.js +426 -0
  89. package/lib/inventory/rustFormulationParser.js +21 -10
  90. package/lib/inventory/source.js +65 -6
  91. package/lib/parsers/huggingfaceManifest.js +9 -1
  92. package/lib/parsers/huggingfaceUtils.js +18 -0
  93. package/lib/parsers/kiroPermissions.js +166 -0
  94. package/lib/server/server.js +36 -34
  95. package/lib/stages/postgen/postgen.js +41 -1
  96. package/lib/stages/postgen/specVersionCompat.js +76 -0
  97. package/lib/validator/bomValidator.js +8 -1
  98. package/lib/validator/complianceEngine.js +30 -3
  99. package/lib/validator/complianceRules.js +1416 -4
  100. package/lib/validator/generated/validate-2.0.mjs +57554 -13851
  101. package/lib/validator/index.js +48 -7
  102. package/lib/validator/reporters/json.js +6 -0
  103. package/package.json +9 -9
  104. package/types/bin/repl.d.ts.map +1 -1
  105. package/types/lib/audit/index.d.ts +6 -2
  106. package/types/lib/audit/index.d.ts.map +1 -1
  107. package/types/lib/cli/bomAssembly.d.ts +16 -0
  108. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  109. package/types/lib/cli/cliOptions.d.ts +6 -0
  110. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  111. package/types/lib/cli/index.d.ts.map +1 -1
  112. package/types/lib/cli/jsBom.d.ts +2 -1
  113. package/types/lib/cli/jsBom.d.ts.map +1 -1
  114. package/types/lib/cli/jvmBom.d.ts +15 -0
  115. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  116. package/types/lib/cli/managedBom.d.ts.map +1 -1
  117. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  118. package/types/lib/core/boundedFile.d.ts +30 -0
  119. package/types/lib/core/boundedFile.d.ts.map +1 -1
  120. package/types/lib/core/env.d.ts +10 -0
  121. package/types/lib/core/env.d.ts.map +1 -1
  122. package/types/lib/core/fs.d.ts +4 -0
  123. package/types/lib/core/fs.d.ts.map +1 -1
  124. package/types/lib/core/globPatterns.d.ts +70 -0
  125. package/types/lib/core/globPatterns.d.ts.map +1 -0
  126. package/types/lib/core/paths.d.ts +9 -0
  127. package/types/lib/core/paths.d.ts.map +1 -1
  128. package/types/lib/core/processTree.d.ts +16 -5
  129. package/types/lib/core/processTree.d.ts.map +1 -1
  130. package/types/lib/core/projectConfig.d.ts +47 -8
  131. package/types/lib/core/projectConfig.d.ts.map +1 -1
  132. package/types/lib/core/toolRegistry.d.ts.map +1 -1
  133. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  134. package/types/lib/ecosystems/gradleutils.d.ts +248 -8
  135. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  136. package/types/lib/ecosystems/jsEvidence.d.ts +14 -1
  137. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  138. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  139. package/types/lib/ecosystems/npmVersionResolution.d.ts +119 -0
  140. package/types/lib/ecosystems/npmVersionResolution.d.ts.map +1 -0
  141. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  142. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  143. package/types/lib/ecosystems/pythonutils.d.ts +1 -1
  144. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  145. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  146. package/types/lib/ecosystems/scalasem.d.ts +58 -16
  147. package/types/lib/ecosystems/scalasem.d.ts.map +1 -1
  148. package/types/lib/evinser/evinser.d.ts +61 -9
  149. package/types/lib/evinser/evinser.d.ts.map +1 -1
  150. package/types/lib/evinser/scalasem.d.ts.map +1 -1
  151. package/types/lib/helpers/agenticToolutils.d.ts +53 -19
  152. package/types/lib/helpers/agenticToolutils.d.ts.map +1 -1
  153. package/types/lib/helpers/bomSigner.d.ts +20 -3
  154. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  155. package/types/lib/helpers/jssSigner.d.ts +288 -0
  156. package/types/lib/helpers/jssSigner.d.ts.map +1 -0
  157. package/types/lib/helpers/vsixutils.d.ts +303 -19
  158. package/types/lib/helpers/vsixutils.d.ts.map +1 -1
  159. package/types/lib/inventory/agentBlueprint.d.ts +70 -0
  160. package/types/lib/inventory/agentBlueprint.d.ts.map +1 -0
  161. package/types/lib/inventory/agentToolNames.d.ts +51 -0
  162. package/types/lib/inventory/agentToolNames.d.ts.map +1 -0
  163. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  164. package/types/lib/inventory/aiInventory.d.ts.map +1 -1
  165. package/types/lib/inventory/aiProviders.d.ts +85 -0
  166. package/types/lib/inventory/aiProviders.d.ts.map +1 -0
  167. package/types/lib/inventory/bomUtils.d.ts.map +1 -1
  168. package/types/lib/inventory/ciParsers/githubActionsLock.d.ts +101 -0
  169. package/types/lib/inventory/ciParsers/githubActionsLock.d.ts.map +1 -0
  170. package/types/lib/inventory/citations.d.ts +5 -0
  171. package/types/lib/inventory/citations.d.ts.map +1 -1
  172. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  173. package/types/lib/inventory/cyclonedx20.d.ts +99 -0
  174. package/types/lib/inventory/cyclonedx20.d.ts.map +1 -0
  175. package/types/lib/inventory/cyclonedx20Common.d.ts +102 -0
  176. package/types/lib/inventory/cyclonedx20Common.d.ts.map +1 -0
  177. package/types/lib/inventory/cyclonedx20Derived.d.ts +74 -0
  178. package/types/lib/inventory/cyclonedx20Derived.d.ts.map +1 -0
  179. package/types/lib/inventory/cyclonedx20Parties.d.ts +91 -0
  180. package/types/lib/inventory/cyclonedx20Parties.d.ts.map +1 -0
  181. package/types/lib/inventory/cyclonedx20Read.d.ts +51 -0
  182. package/types/lib/inventory/cyclonedx20Read.d.ts.map +1 -0
  183. package/types/lib/inventory/cyclonedx20ReadCommon.d.ts +69 -0
  184. package/types/lib/inventory/cyclonedx20ReadCommon.d.ts.map +1 -0
  185. package/types/lib/inventory/cyclonedx20ReadComponents.d.ts +77 -0
  186. package/types/lib/inventory/cyclonedx20ReadComponents.d.ts.map +1 -0
  187. package/types/lib/inventory/cyclonedx20ReadContext.d.ts +118 -0
  188. package/types/lib/inventory/cyclonedx20ReadContext.d.ts.map +1 -0
  189. package/types/lib/inventory/cyclonedx20ReadEvidence.d.ts +40 -0
  190. package/types/lib/inventory/cyclonedx20ReadEvidence.d.ts.map +1 -0
  191. package/types/lib/inventory/cyclonedx20ReadSections.d.ts +45 -0
  192. package/types/lib/inventory/cyclonedx20ReadSections.d.ts.map +1 -0
  193. package/types/lib/inventory/cyclonedx20Render.d.ts +53 -0
  194. package/types/lib/inventory/cyclonedx20Render.d.ts.map +1 -0
  195. package/types/lib/inventory/cyclonedx20RenderComponents.d.ts +73 -0
  196. package/types/lib/inventory/cyclonedx20RenderComponents.d.ts.map +1 -0
  197. package/types/lib/inventory/cyclonedx20RenderDefinitions.d.ts +29 -0
  198. package/types/lib/inventory/cyclonedx20RenderDefinitions.d.ts.map +1 -0
  199. package/types/lib/inventory/cyclonedx20RenderEvidence.d.ts +110 -0
  200. package/types/lib/inventory/cyclonedx20RenderEvidence.d.ts.map +1 -0
  201. package/types/lib/inventory/cyclonedx20RenderHardware.d.ts +42 -0
  202. package/types/lib/inventory/cyclonedx20RenderHardware.d.ts.map +1 -0
  203. package/types/lib/inventory/cyclonedx20RenderPatents.d.ts +26 -0
  204. package/types/lib/inventory/cyclonedx20RenderPatents.d.ts.map +1 -0
  205. package/types/lib/inventory/cyclonedx20RenderRefIds.d.ts +55 -0
  206. package/types/lib/inventory/cyclonedx20RenderRefIds.d.ts.map +1 -0
  207. package/types/lib/inventory/cyclonedx20RenderSections.d.ts +45 -0
  208. package/types/lib/inventory/cyclonedx20RenderSections.d.ts.map +1 -0
  209. package/types/lib/inventory/cyclonedx20Tables.d.ts +134 -0
  210. package/types/lib/inventory/cyclonedx20Tables.d.ts.map +1 -0
  211. package/types/lib/inventory/deps.d.ts +35 -2
  212. package/types/lib/inventory/deps.d.ts.map +1 -1
  213. package/types/lib/inventory/dosai.d.ts +19 -0
  214. package/types/lib/inventory/dosai.d.ts.map +1 -1
  215. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  216. package/types/lib/inventory/dotnetRuntime.d.ts +51 -4
  217. package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -1
  218. package/types/lib/inventory/ecosystemFormulationParsers.d.ts +73 -0
  219. package/types/lib/inventory/ecosystemFormulationParsers.d.ts.map +1 -0
  220. package/types/lib/inventory/envcontext.d.ts +22 -6
  221. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  222. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  223. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  224. package/types/lib/inventory/hbom.d.ts +11 -0
  225. package/types/lib/inventory/hbom.d.ts.map +1 -1
  226. package/types/lib/inventory/hbomLoader.d.ts +54 -0
  227. package/types/lib/inventory/hbomLoader.d.ts.map +1 -1
  228. package/types/lib/inventory/jvmLocalRepos.d.ts +5 -1
  229. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -1
  230. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  231. package/types/lib/inventory/occurrenceKey.d.ts +52 -0
  232. package/types/lib/inventory/occurrenceKey.d.ts.map +1 -0
  233. package/types/lib/inventory/pqcMigration.d.ts +84 -0
  234. package/types/lib/inventory/pqcMigration.d.ts.map +1 -0
  235. package/types/lib/inventory/rustFormulationParser.d.ts +7 -2
  236. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  237. package/types/lib/inventory/scalaCoords.d.ts.map +1 -1
  238. package/types/lib/inventory/scalaDedupe.d.ts +43 -0
  239. package/types/lib/inventory/scalaDedupe.d.ts.map +1 -0
  240. package/types/lib/inventory/source.d.ts +15 -0
  241. package/types/lib/inventory/source.d.ts.map +1 -1
  242. package/types/lib/parsers/huggingfaceManifest.d.ts +10 -1
  243. package/types/lib/parsers/huggingfaceManifest.d.ts.map +1 -1
  244. package/types/lib/parsers/huggingfaceUtils.d.ts +12 -0
  245. package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
  246. package/types/lib/parsers/kiroPermissions.d.ts +62 -0
  247. package/types/lib/parsers/kiroPermissions.d.ts.map +1 -0
  248. package/types/lib/parsers/vscodeExtensionId.d.ts +48 -0
  249. package/types/lib/parsers/vscodeExtensionId.d.ts.map +1 -0
  250. package/types/lib/server/server.d.ts.map +1 -1
  251. package/types/lib/stages/postgen/postgen.d.ts +7 -0
  252. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  253. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  254. package/types/lib/stages/postgen/specVersionCompat.d.ts +25 -0
  255. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  256. package/types/lib/validator/bomValidator.d.ts +5 -1
  257. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  258. package/types/lib/validator/complianceEngine.d.ts +4 -4
  259. package/types/lib/validator/complianceEngine.d.ts.map +1 -1
  260. package/types/lib/validator/complianceRules.d.ts +14 -2
  261. package/types/lib/validator/complianceRules.d.ts.map +1 -1
  262. package/types/lib/validator/index.d.ts +10 -0
  263. package/types/lib/validator/index.d.ts.map +1 -1
  264. package/types/lib/validator/reporters/json.d.ts.map +1 -1
package/lib/core/fs.js CHANGED
@@ -140,10 +140,126 @@ export function safeIsExecutableSync(filePath) {
140
140
  }
141
141
  }
142
142
 
143
+ // writeFileSync flags that create or replace a file, which the no-follow
144
+ // write below reproduces with open(2) flags.
145
+ const NO_FOLLOW_WRITE_FLAGS = new Set([
146
+ "a",
147
+ "a+",
148
+ "as",
149
+ "as+",
150
+ "ax",
151
+ "ax+",
152
+ "w",
153
+ "w+",
154
+ "wx",
155
+ "wx+",
156
+ ]);
157
+
158
+ /**
159
+ * Determine whether a path names a device or a process file, such as
160
+ * /dev/stdout, /dev/null or the /dev/fd entry of a shell process
161
+ * substitution. These are symbolic links an operator names on purpose, so
162
+ * they are written through as given.
163
+ *
164
+ * @param {string} filePath File path to check.
165
+ * @returns {boolean} True for paths under /dev or /proc on POSIX systems.
166
+ */
167
+ function isDeviceOrProcessPath(filePath) {
168
+ if (isWin) {
169
+ return false;
170
+ }
171
+ const absolutePath = resolve(filePath);
172
+ return absolutePath.startsWith("/dev/") || absolutePath.startsWith("/proc/");
173
+ }
174
+
175
+ /**
176
+ * Convert a writeFileSync flag string into open(2) flags.
177
+ *
178
+ * @param {string} flag Flag such as "w", "a" or "wx".
179
+ * @returns {number} Numeric open flags.
180
+ */
181
+ function writeOpenFlags(flag) {
182
+ let flags =
183
+ fsConstants.O_CREAT |
184
+ (flag.includes("+") ? fsConstants.O_RDWR : fsConstants.O_WRONLY) |
185
+ (flag.startsWith("a") ? fsConstants.O_APPEND : fsConstants.O_TRUNC);
186
+ if (flag.includes("x")) {
187
+ flags |= fsConstants.O_EXCL;
188
+ }
189
+ return flags;
190
+ }
191
+
192
+ /**
193
+ * Write a file without following a symbolic link in the last component of
194
+ * its path. A link found there is removed and a regular file is created in
195
+ * its place, so a link committed to a scanned repository under a name cdxgen
196
+ * writes, such as bom.json, cannot send the write to the file it points at.
197
+ * The open uses O_NOFOLLOW where the platform has it, so a link that appears
198
+ * after the check makes the write fail rather than follow it. Links in the
199
+ * parent directories are followed as usual.
200
+ *
201
+ * @param {string} filePath File path to write.
202
+ * @param {string|Buffer|Uint8Array} data Data to write.
203
+ * @param {Object|string} [options] writeFileSync options or encoding.
204
+ * @returns {boolean} True when a symbolic link was replaced.
205
+ */
206
+ function writeFileWithoutFollowingLink(filePath, data, options) {
207
+ const flag =
208
+ (typeof options === "object" && options?.flag
209
+ ? String(options.flag)
210
+ : undefined) || "w";
211
+ if (
212
+ typeof filePath !== "string" ||
213
+ !NO_FOLLOW_WRITE_FLAGS.has(flag) ||
214
+ isDeviceOrProcessPath(filePath)
215
+ ) {
216
+ writeFileSync(filePath, data, options);
217
+ return false;
218
+ }
219
+ let replacedLink = false;
220
+ let pathStats;
221
+ try {
222
+ pathStats = lstatSync(filePath);
223
+ } catch {
224
+ pathStats = undefined;
225
+ }
226
+ if (pathStats?.isSymbolicLink()) {
227
+ // rmSync removes the link itself, including a directory link on Windows.
228
+ rmSync(filePath, { force: true });
229
+ replacedLink = true;
230
+ }
231
+ const openFlags =
232
+ writeOpenFlags(flag) |
233
+ (fsConstants.O_NOFOLLOW || 0) |
234
+ (replacedLink ? fsConstants.O_EXCL : 0);
235
+ const mode =
236
+ typeof options === "object" && options?.mode !== undefined
237
+ ? options.mode
238
+ : 0o666;
239
+ const fd = openSync(filePath, openFlags, mode);
240
+ try {
241
+ writeFileSync(fd, data, {
242
+ encoding: typeof options === "string" ? options : options?.encoding,
243
+ });
244
+ } finally {
245
+ closeSync(fd);
246
+ }
247
+ if (replacedLink) {
248
+ console.warn(
249
+ `'${filePath}' was a symbolic link. cdxgen replaced the link with a regular file instead of writing to the file it pointed at.`,
250
+ );
251
+ }
252
+ return replacedLink;
253
+ }
254
+
143
255
  /**
144
256
  * Permission- and dry-run-aware wrapper around writeFileSync. Records the
145
257
  * activity and returns undefined when blocked.
146
258
  *
259
+ * The last component of the path is never followed when it is a symbolic
260
+ * link: the link is replaced with a regular file. Paths under /dev and /proc,
261
+ * such as /dev/stdout, are written through as given.
262
+ *
147
263
  * @param {string} filePath File path to write.
148
264
  * @param {string|Buffer} data Data to write.
149
265
  * @param {Object} [options] writeFileSync options (encoding, mode, flag).
@@ -153,8 +269,15 @@ export function safeWriteSync(filePath, data, options) {
153
269
  if (isWriteBlocked(filePath)) {
154
270
  return undefined;
155
271
  }
156
- writeFileSync(filePath, data, options);
157
- recordFilesystemActivity("write", filePath, "completed");
272
+ const replacedLink = writeFileWithoutFollowingLink(filePath, data, options);
273
+ recordFilesystemActivity(
274
+ "write",
275
+ filePath,
276
+ "completed",
277
+ replacedLink
278
+ ? "Replaced a symbolic link with a regular file instead of following it."
279
+ : undefined,
280
+ );
158
281
  }
159
282
 
160
283
  /**
@@ -173,7 +296,7 @@ export function safeWriteChunksSync(filePath, chunks) {
173
296
  return undefined;
174
297
  }
175
298
  const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
176
- const fd = openSync(tempPath, "w");
299
+ const fd = openSync(tempPath, "wx");
177
300
  try {
178
301
  for (const chunk of chunks) {
179
302
  const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Longest exclude or include pattern cdxgen accepts from an untrusted source,
3
+ * such as a server request or a config file in the scanned directory.
4
+ */
5
+ export const UNTRUSTED_GLOB_PATTERN_MAX_LENGTH = 512;
6
+
7
+ /**
8
+ * Most patterns a list of untrusted exclude or include patterns may stand
9
+ * for once brace expressions are expanded. Every expanded pattern is matched
10
+ * against every file the scan walks, so the total, not the length of the
11
+ * list as written, is what decides the cost.
12
+ */
13
+ export const UNTRUSTED_GLOB_EXPANSION_BUDGET = 128;
14
+
15
+ function isIntegerText(text) {
16
+ const digits = text.startsWith("-") ? text.slice(1) : text;
17
+ return (
18
+ digits.length > 0 && [...digits].every((char) => char >= "0" && char <= "9")
19
+ );
20
+ }
21
+
22
+ function isLetter(text) {
23
+ return (
24
+ text.length === 1 &&
25
+ ((text >= "a" && text <= "z") || (text >= "A" && text <= "Z"))
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Count the members of a brace range such as `1..10`, `a..z` or `0..100..5`.
31
+ *
32
+ * @param {string} body text between the braces
33
+ * @param {number} limit value returned for ranges at or beyond the limit
34
+ * @returns {number|undefined} member count, or undefined when the body is
35
+ * not a range
36
+ */
37
+ function braceRangeSize(body, limit) {
38
+ const parts = body.split("..");
39
+ if (parts.length !== 2 && parts.length !== 3) {
40
+ return undefined;
41
+ }
42
+ const [start, end, step = "1"] = parts;
43
+ if (!isIntegerText(step)) {
44
+ return undefined;
45
+ }
46
+ const increment = Math.abs(Number(step)) || 1;
47
+ let distance;
48
+ if (isIntegerText(start) && isIntegerText(end)) {
49
+ distance = Math.abs(Number(end) - Number(start));
50
+ } else if (isLetter(start) && isLetter(end)) {
51
+ distance = Math.abs(end.charCodeAt(0) - start.charCodeAt(0));
52
+ } else {
53
+ return undefined;
54
+ }
55
+ const size = Math.floor(distance / increment) + 1;
56
+ return Number.isFinite(size) && size < limit ? size : limit;
57
+ }
58
+
59
+ function newBraceGroup(start) {
60
+ return {
61
+ current: 1,
62
+ hasAnyComma: false,
63
+ hasComma: false,
64
+ hasDots: false,
65
+ hasGroup: false,
66
+ start,
67
+ sum: 0,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Count, from above, how many patterns a glob's brace expressions expand to,
73
+ * following the brace-expansion package that minimatch uses:
74
+ *
75
+ * - alternatives in `{a,b}` add up, and groups side by side or inside one
76
+ * another multiply;
77
+ * - a range such as `{1..9}`, `{a..z}` or `{0..100..5}` counts its members;
78
+ * - a group with no comma of its own but a list inside it, such as
79
+ * `{{a,b}c}`, is expanded and each result is braced and expanded again,
80
+ * so it counts as its contents, unless its text holds `..` and a result
81
+ * could become a range of any size.
82
+ *
83
+ * brace-expansion reads braces that form neither a list nor a range in ways
84
+ * that depend on the rest of the pattern, so a group that is neither, a
85
+ * closing brace with no group open, a group left open, and the `..` case
86
+ * above all count as more than `cap`. Patterns that list alternatives or
87
+ * ranges, such as `{test,tests}` or `*.{js,ts}`, get the exact count, or one
88
+ * slightly above it where a letter range such as `{Z..c}` spans the `\` that
89
+ * brace-expansion leaves out. `\` escapes the next character. The count stops growing at
90
+ * `cap + 1` and each character is read once, so a pattern that would expand
91
+ * to millions costs no more to count than its length.
92
+ *
93
+ * @param {string} pattern glob pattern
94
+ * @param {number} [cap] largest count of interest
95
+ * @returns {number} upper bound on the number of expanded patterns, at most
96
+ * `cap + 1`
97
+ */
98
+ export function globBraceExpansionCount(
99
+ pattern,
100
+ cap = Number.MAX_SAFE_INTEGER - 1,
101
+ ) {
102
+ const limit = cap + 1;
103
+ const clamp = (value) => Math.min(value, limit);
104
+ const groups = [newBraceGroup(0)];
105
+ const text = String(pattern);
106
+ for (let index = 0; index < text.length; index += 1) {
107
+ const character = text[index];
108
+ if (character === "\\") {
109
+ index += 1;
110
+ continue;
111
+ }
112
+ const group = groups.at(-1);
113
+ if (character === "{") {
114
+ group.hasGroup = true;
115
+ groups.push(newBraceGroup(index + 1));
116
+ } else if (character === "." && text[index + 1] === ".") {
117
+ group.hasDots = true;
118
+ } else if (character === "," && groups.length > 1) {
119
+ group.sum = clamp(group.sum + group.current);
120
+ group.current = 1;
121
+ group.hasComma = true;
122
+ group.hasAnyComma = true;
123
+ } else if (character === "}") {
124
+ if (groups.length === 1) {
125
+ return limit;
126
+ }
127
+ let count;
128
+ if (group.hasComma) {
129
+ count = clamp(group.sum + group.current);
130
+ } else if (group.hasAnyComma) {
131
+ count = group.hasDots ? limit : group.current;
132
+ } else if (!group.hasGroup) {
133
+ count = braceRangeSize(text.slice(group.start, index), limit);
134
+ }
135
+ if (count === undefined) {
136
+ return limit;
137
+ }
138
+ groups.pop();
139
+ const parent = groups.at(-1);
140
+ parent.current = clamp(parent.current * count);
141
+ parent.hasAnyComma ||= group.hasAnyComma;
142
+ parent.hasDots ||= group.hasDots;
143
+ }
144
+ }
145
+ return groups.length === 1 ? groups[0].current : limit;
146
+ }
147
+
148
+ /**
149
+ * Split exclude or include patterns from an untrusted source into the ones
150
+ * cdxgen uses and the ones it refuses. Patterns are taken in order while
151
+ * they fit the expansion budget, so a list cannot multiply the work of a
152
+ * scan by brace expansion or sheer number.
153
+ *
154
+ * @param {unknown} patterns a pattern, a list of patterns, or nothing
155
+ * @param {Object} [options] bound options
156
+ * @param {boolean} [options.allowBraces=true] refuse every pattern with a
157
+ * brace when false
158
+ * @param {number} [options.budget] expanded patterns allowed in all
159
+ * @param {number} [options.used=0] expanded patterns earlier lists have
160
+ * already taken from the budget
161
+ * @returns {{accepted: string[], refused: {pattern: string, reason: string}[], expansions: number}}
162
+ * the patterns to use, the ones refused with the reason, and the expanded
163
+ * patterns the accepted ones use
164
+ */
165
+ export function boundUntrustedGlobPatterns(
166
+ patterns,
167
+ {
168
+ allowBraces = true,
169
+ budget = UNTRUSTED_GLOB_EXPANSION_BUDGET,
170
+ used = 0,
171
+ } = {},
172
+ ) {
173
+ const entries =
174
+ patterns === undefined || patterns === null
175
+ ? []
176
+ : Array.isArray(patterns)
177
+ ? patterns
178
+ : [patterns];
179
+ const accepted = [];
180
+ const refused = [];
181
+ let expansions = 0;
182
+ for (const entry of entries) {
183
+ if (typeof entry !== "string" && typeof entry !== "number") {
184
+ refused.push({ pattern: String(entry), reason: "is not a string" });
185
+ continue;
186
+ }
187
+ const pattern = String(entry);
188
+ if (pattern.length > UNTRUSTED_GLOB_PATTERN_MAX_LENGTH) {
189
+ refused.push({
190
+ pattern,
191
+ reason: `is longer than ${UNTRUSTED_GLOB_PATTERN_MAX_LENGTH} characters`,
192
+ });
193
+ continue;
194
+ }
195
+ if (!allowBraces && pattern.includes("{")) {
196
+ refused.push({ pattern, reason: "uses braces" });
197
+ continue;
198
+ }
199
+ const count = globBraceExpansionCount(pattern, budget);
200
+ if (count > budget) {
201
+ refused.push({
202
+ pattern,
203
+ reason: `expands to more than ${budget} patterns`,
204
+ });
205
+ continue;
206
+ }
207
+ if (used + expansions + count > budget) {
208
+ refused.push({
209
+ pattern,
210
+ reason: `would bring the patterns past ${budget} after brace expansion`,
211
+ });
212
+ continue;
213
+ }
214
+ expansions += count;
215
+ accepted.push(pattern);
216
+ }
217
+ return { accepted, expansions, refused };
218
+ }
@@ -5,6 +5,8 @@
5
5
  * and the dosai runners (layer 2) can share it.
6
6
  */
7
7
 
8
+ import process from "node:process";
9
+
8
10
  import { DEBUG_MODE } from "./activity.js";
9
11
  import { safeSpawnSync } from "./fs.js";
10
12
  import { isWin } from "./paths.js";
@@ -24,6 +26,54 @@ export function formatDuration(ms) {
24
26
  return `${Math.round(ms / 100) / 10} seconds`;
25
27
  }
26
28
 
29
+ /**
30
+ * Longest the PowerShell process snapshot behind reapProcessTree may run.
31
+ */
32
+ const REAP_TIMEOUT_MS = 60000;
33
+
34
+ /**
35
+ * Longest reapProcessTree waits for the processes it stopped to exit.
36
+ */
37
+ const REAP_EXIT_WAIT_MS = 10000;
38
+
39
+ /**
40
+ * Block the calling thread for a number of milliseconds.
41
+ *
42
+ * @param {number} ms Milliseconds to wait
43
+ */
44
+ function sleepSync(ms) {
45
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
46
+ }
47
+
48
+ /**
49
+ * Whether a process with this pid is still running.
50
+ *
51
+ * @param {number} pid Process id
52
+ * @returns {boolean} true while the process exists
53
+ */
54
+ function isRunning(pid) {
55
+ try {
56
+ process.kill(pid, 0);
57
+ return true;
58
+ } catch (err) {
59
+ // EPERM means the process exists but belongs to someone else.
60
+ return err?.code === "EPERM";
61
+ }
62
+ }
63
+
64
+ /**
65
+ * A UTC instant as a DMTF datetime, the form WQL compares CIM_DATETIME
66
+ * properties such as Win32_Process.CreationDate against.
67
+ *
68
+ * @param {number} ms Epoch milliseconds
69
+ * @returns {string} The instant as `yyyymmddHHMMSS.mmmmmm+000`
70
+ */
71
+ export function toDmtfUtc(ms) {
72
+ const date = new Date(ms);
73
+ const pad = (value, width = 2) => `${value}`.padStart(width, "0");
74
+ return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}000+000`;
75
+ }
76
+
27
77
  /**
28
78
  * Stop the descendants a timed-out run left behind on Windows.
29
79
  *
@@ -32,13 +82,16 @@ export function formatDuration(ms) {
32
82
  * workers, or the build hosts a .NET analyzer starts keep running while still
33
83
  * pointing at the dead process's pid. `taskkill /T` on that pid finds nothing,
34
84
  * because the root is already gone. The live descendants are therefore found
35
- * by parent pid in a process snapshot and stopped, limited to processes
36
- * created after the run started. The root's own children must also predate
37
- * its end: once the root is gone its pid can be handed to an unrelated
38
- * process, whose children would otherwise look like the run's. Elsewhere this
85
+ * by parent pid in a PowerShell process snapshot, limited to processes
86
+ * created after the run started, and stopped from Node.js. The root's own
87
+ * children must also predate its end: once the root is gone its pid can be
88
+ * handed to an unrelated process, whose children would otherwise look like
89
+ * the run's. Elsewhere this
39
90
  * does nothing: an orphan is re-parented and can no longer be told apart.
40
91
  * It returns once the stopped processes have exited, or after ten seconds,
41
- * so the caller can remove files the run left half-written.
92
+ * so the caller can remove files the run left half-written. When the
93
+ * snapshot itself fails or runs past its time limit it warns and returns an
94
+ * empty list, since processes may then still be running.
42
95
  *
43
96
  * @param {number|undefined} rootPid pid of the process cdxgen spawned
44
97
  * @param {number} startedAt Epoch milliseconds at which the run started
@@ -55,15 +108,24 @@ export function reapProcessTree(
55
108
  if (!isWin || !Number.isInteger(rootPid) || rootPid <= 0) {
56
109
  return [];
57
110
  }
58
- // All three values are integers, so interpolating them cannot inject anything.
111
+ // All three values are integers, and the DMTF string is built from digits
112
+ // only, so interpolating them cannot inject anything.
59
113
  const since = Math.max(0, Math.floor(startedAt) - 1000);
60
114
  const until = Math.max(since, Math.floor(endedAt) + 1000);
115
+ const snapshotProperties = "-Property ProcessId,ParentProcessId,CreationDate";
61
116
  const script = [
62
117
  `$root = ${rootPid}`,
63
118
  `$since = [DateTimeOffset]::FromUnixTimeMilliseconds(${since}).UtcDateTime`,
64
119
  `$until = [DateTimeOffset]::FromUnixTimeMilliseconds(${until}).UtcDateTime`,
120
+ // WMI drops the processes that predate the run before PowerShell sees
121
+ // them, which keeps the snapshot small on a busy host. The loop below
122
+ // applies the same bound, so a WMI that rejects the filter only costs the
123
+ // full snapshot.
124
+ "$procs = @()",
125
+ `try { $procs = @(Get-CimInstance Win32_Process -Filter "CreationDate >= '${toDmtfUtc(since)}'" ${snapshotProperties} -ErrorAction Stop) }`,
126
+ `catch { $procs = @(Get-CimInstance Win32_Process ${snapshotProperties}) }`,
65
127
  "$children = @{}",
66
- "foreach ($p in Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate) {",
128
+ "foreach ($p in $procs) {",
67
129
  " if (-not $p.CreationDate) { continue }",
68
130
  " $created = $p.CreationDate.ToUniversalTime()",
69
131
  " if ($created -lt $since) { continue }",
@@ -78,22 +140,47 @@ export function reapProcessTree(
78
140
  " if ($child -and -not $found.Contains($child)) { $found.Add($child); $queue.Enqueue($child) }",
79
141
  " }",
80
142
  "}",
81
- "foreach ($child in $found) { Stop-Process -Id $child -Force -ErrorAction SilentlyContinue }",
82
- // Stop-Process returns before the process exits, and a process that is
83
- // still exiting keeps its files open, so wait for the exits.
84
- "if ($found.Count) { Wait-Process -Id $found.ToArray() -Timeout 10 -ErrorAction SilentlyContinue }",
85
143
  "$found -join ','",
86
144
  ].join("\n");
87
145
  const result = safeSpawnSync(
88
146
  "powershell",
89
147
  ["-NoProfile", "-NonInteractive", "-Command", script],
90
- { timeout: 60000 },
148
+ { timeout: REAP_TIMEOUT_MS },
91
149
  );
150
+ if (result?.error || result?.status !== 0) {
151
+ // An empty result here is not evidence that nothing was left running.
152
+ console.warn(
153
+ result?.error?.code === "ETIMEDOUT"
154
+ ? `Could not look for processes the timed-out ${toolName} run left behind: the process snapshot took longer than ${formatDuration(REAP_TIMEOUT_MS)}.`
155
+ : `Could not look for processes the timed-out ${toolName} run left behind: PowerShell ${result?.error ? `failed to start (${result.error.message})` : `exited with status ${result?.status}`}.`,
156
+ );
157
+ return [];
158
+ }
92
159
  const reaped = `${result?.stdout || ""}`
93
160
  .trim()
94
161
  .split(",")
95
162
  .map((pid) => Number.parseInt(pid, 10))
96
163
  .filter((pid) => pid > 0);
164
+ for (const pid of reaped) {
165
+ try {
166
+ process.kill(pid, "SIGKILL");
167
+ } catch {
168
+ // The process exited between the snapshot and the kill.
169
+ }
170
+ }
171
+ // A process that is still exiting keeps its files open, so wait for the
172
+ // exits, but never longer than REAP_EXIT_WAIT_MS.
173
+ const deadline = Date.now() + REAP_EXIT_WAIT_MS;
174
+ let running = reaped.filter(isRunning);
175
+ while (running.length && Date.now() < deadline) {
176
+ sleepSync(100);
177
+ running = running.filter(isRunning);
178
+ }
179
+ if (running.length) {
180
+ console.warn(
181
+ `${running.length} process(es) the timed-out ${toolName} run left behind were still exiting after ${formatDuration(REAP_EXIT_WAIT_MS)}: ${running.join(", ")}.`,
182
+ );
183
+ }
97
184
  if (DEBUG_MODE) {
98
185
  console.log(
99
186
  reaped.length