@cdxgen/cdxgen 13.0.0 → 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 (170) hide show
  1. package/README.md +26 -13
  2. package/bin/audit.js +6 -0
  3. package/bin/cdxgen.js +140 -8
  4. package/bin/convert.js +7 -0
  5. package/bin/evinse.js +1 -1
  6. package/bin/hbom.js +13 -4
  7. package/bin/tracebom.js +9 -1
  8. package/data/component-tags.json +8 -1
  9. package/data/frameworks-list.json +86 -1
  10. package/data/remediations.json +1144 -0
  11. package/data/rules/asar-archives.yaml +1 -1
  12. package/data/rules/build-fidelity.yaml +782 -0
  13. package/data/rules/package-integrity.yaml +13 -13
  14. package/lib/audit/index.js +48 -4
  15. package/lib/audit/scoring.js +2 -8
  16. package/lib/cli/bomAssembly.js +83 -15
  17. package/lib/cli/cliOptions.js +124 -7
  18. package/lib/cli/index.js +76 -48
  19. package/lib/cli/jsBom.js +32 -3
  20. package/lib/cli/jvmBom.js +1494 -1315
  21. package/lib/cli/managedBom.js +97 -28
  22. package/lib/cli/nativeBom.js +189 -10
  23. package/lib/core/buildLedger.js +799 -0
  24. package/lib/core/deferredExit.js +141 -0
  25. package/lib/core/env.js +69 -1
  26. package/lib/core/fs.js +60 -0
  27. package/lib/core/logger.js +76 -25
  28. package/lib/core/severity.js +16 -0
  29. package/lib/core/syncFileWriter.js +106 -0
  30. package/lib/core/ui.js +46 -2
  31. package/lib/ecosystems/ecosystems.js +9 -0
  32. package/lib/ecosystems/gradleutils.js +355 -15
  33. package/lib/ecosystems/parsers-js.js +26 -0
  34. package/lib/ecosystems/parsers-jvm.js +9 -0
  35. package/lib/ecosystems/parsers-misc.js +16 -2
  36. package/lib/ecosystems/parsers-python.js +171 -35
  37. package/lib/ecosystems/parsers-rust.js +12 -2
  38. package/lib/ecosystems/pypiClassifiers.js +326 -0
  39. package/lib/ecosystems/pythonutils.js +49 -1
  40. package/lib/ecosystems/rubyutils.js +13 -0
  41. package/lib/ecosystems/utils.js +9 -1
  42. package/lib/evinser/evinser.js +30 -12
  43. package/lib/helpers/remote/dependency-track.js +81 -14
  44. package/lib/inventory/auditCategories.js +9 -0
  45. package/lib/inventory/depsUtils.js +72 -0
  46. package/lib/inventory/dosai.js +431 -22
  47. package/lib/inventory/envcontext.js +211 -19
  48. package/lib/inventory/fetchBatch.js +57 -0
  49. package/lib/inventory/jvmToolEnv.js +593 -0
  50. package/lib/inventory/osinfo.js +123 -16
  51. package/lib/inventory/table.js +9 -5
  52. package/lib/inventory/toolRequirements.js +903 -0
  53. package/lib/managers/binary.js +216 -40
  54. package/lib/managers/docker.js +26 -5
  55. package/lib/server/server.js +1 -1
  56. package/lib/stages/postgen/annotationBuilder.js +97 -0
  57. package/lib/stages/postgen/auditBom.js +76 -79
  58. package/lib/stages/postgen/introspection/annotate.js +320 -0
  59. package/lib/stages/postgen/introspection/commandFacts.js +293 -0
  60. package/lib/stages/postgen/introspection/emit.js +192 -0
  61. package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
  62. package/lib/stages/postgen/introspection/markers.js +227 -0
  63. package/lib/stages/postgen/introspection/reflect.js +1214 -0
  64. package/lib/stages/postgen/introspection/report.js +1208 -0
  65. package/lib/stages/postgen/introspection/score.js +1067 -0
  66. package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
  67. package/lib/stages/postgen/parentEdge.js +156 -0
  68. package/lib/stages/postgen/postgen.js +115 -2
  69. package/lib/stages/postgen/ruleEngine.js +17 -0
  70. package/lib/stages/pregen/envAudit.js +42 -2
  71. package/lib/stages/pregen/pregen.js +1171 -6
  72. package/lib/validator/bomValidator.js +11 -2
  73. package/package.json +14 -14
  74. package/types/lib/audit/index.d.ts.map +1 -1
  75. package/types/lib/audit/scoring.d.ts +2 -8
  76. package/types/lib/audit/scoring.d.ts.map +1 -1
  77. package/types/lib/cli/bomAssembly.d.ts +14 -0
  78. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  79. package/types/lib/cli/cliOptions.d.ts +2 -1
  80. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  81. package/types/lib/cli/index.d.ts +8 -0
  82. package/types/lib/cli/index.d.ts.map +1 -1
  83. package/types/lib/cli/jsBom.d.ts.map +1 -1
  84. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  85. package/types/lib/cli/managedBom.d.ts.map +1 -1
  86. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  87. package/types/lib/core/buildLedger.d.ts +272 -0
  88. package/types/lib/core/buildLedger.d.ts.map +1 -0
  89. package/types/lib/core/deferredExit.d.ts +84 -0
  90. package/types/lib/core/deferredExit.d.ts.map +1 -0
  91. package/types/lib/core/env.d.ts +22 -1
  92. package/types/lib/core/env.d.ts.map +1 -1
  93. package/types/lib/core/fs.d.ts +21 -0
  94. package/types/lib/core/fs.d.ts.map +1 -1
  95. package/types/lib/core/logger.d.ts +14 -1
  96. package/types/lib/core/logger.d.ts.map +1 -1
  97. package/types/lib/core/severity.d.ts +11 -0
  98. package/types/lib/core/severity.d.ts.map +1 -0
  99. package/types/lib/core/syncFileWriter.d.ts +33 -0
  100. package/types/lib/core/syncFileWriter.d.ts.map +1 -0
  101. package/types/lib/core/ui.d.ts +1 -1
  102. package/types/lib/core/ui.d.ts.map +1 -1
  103. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  104. package/types/lib/ecosystems/gradleutils.d.ts +33 -0
  105. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  106. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  107. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  108. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  109. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  110. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  111. package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
  112. package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
  113. package/types/lib/ecosystems/pythonutils.d.ts +20 -0
  114. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  115. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  116. package/types/lib/ecosystems/utils.d.ts +4 -4
  117. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  118. package/types/lib/evinser/evinser.d.ts +5 -1
  119. package/types/lib/evinser/evinser.d.ts.map +1 -1
  120. package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
  121. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  122. package/types/lib/inventory/auditCategories.d.ts +8 -0
  123. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  124. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  125. package/types/lib/inventory/dosai.d.ts +20 -0
  126. package/types/lib/inventory/dosai.d.ts.map +1 -1
  127. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  128. package/types/lib/inventory/fetchBatch.d.ts +9 -0
  129. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  130. package/types/lib/inventory/jvmToolEnv.d.ts +189 -0
  131. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
  132. package/types/lib/inventory/osinfo.d.ts +83 -2
  133. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  134. package/types/lib/inventory/table.d.ts +4 -1
  135. package/types/lib/inventory/table.d.ts.map +1 -1
  136. package/types/lib/inventory/toolRequirements.d.ts +216 -0
  137. package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
  138. package/types/lib/managers/binary.d.ts +71 -0
  139. package/types/lib/managers/binary.d.ts.map +1 -1
  140. package/types/lib/managers/docker.d.ts.map +1 -1
  141. package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
  142. package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
  143. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  144. package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
  145. package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
  146. package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
  147. package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
  148. package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
  149. package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
  150. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
  151. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
  152. package/types/lib/stages/postgen/introspection/markers.d.ts +57 -0
  153. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
  154. package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
  155. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
  156. package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
  157. package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
  158. package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
  159. package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
  160. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
  161. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
  162. package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
  163. package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
  164. package/types/lib/stages/postgen/postgen.d.ts +2 -2
  165. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  166. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  167. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  168. package/types/lib/stages/pregen/pregen.d.ts +131 -0
  169. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  170. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Deferred `fail-on-error` exits for introspected runs.
3
+ *
4
+ * `--fail-on-error` promises an operator that a failing dependency extractor
5
+ * never produces a plausible-but-incomplete BOM, so the extractors exit the
6
+ * process the moment a build tool fails. Under `--introspect` that exit
7
+ * destroys the very evidence the run exists to produce: the ledger events
8
+ * recorded a statement earlier die with the process, `postProcess` never
9
+ * runs, and neither the BOM nor the fidelity report is written.
10
+ *
11
+ * On an introspected run the extractor instead records the failure here and
12
+ * throws {@link DeferredFailOnError}, which the extractor catches at its
13
+ * completion point so it still returns everything it collected before the
14
+ * failure — the incomplete-result fallbacks stay skipped, exactly as
15
+ * `fail-on-error` demands. The CLI then writes the BOM and both reports and
16
+ * exits with the dedicated {@link INTROSPECTION_FAILURE_EXIT_CODE} status, so
17
+ * the exit code still claims "an extractor failed", distinct from the
18
+ * fidelity gate's 4.
19
+ *
20
+ * Without introspection this module is a pass-through to `process.exit(1)`;
21
+ * the default contract of the flag is unchanged.
22
+ */
23
+
24
+ import {
25
+ isIntrospectionEnabled,
26
+ LEDGER_EVENT_KINDS,
27
+ recordLedgerEvent,
28
+ } from "./buildLedger.js";
29
+ import { thoughtLog } from "./logger.js";
30
+
31
+ /**
32
+ * Exit status used when an introspected run completes its outputs despite a
33
+ * `--fail-on-error` extractor failure. Distinct from 1 (generic failure),
34
+ * 3 (TEA publish failure) and 4 (the introspection fidelity gate).
35
+ *
36
+ * @type {number}
37
+ */
38
+ export const INTROSPECTION_FAILURE_EXIT_CODE = 5;
39
+
40
+ /**
41
+ * Control-flow signal thrown by {@link deferFailOnError} on an introspected
42
+ * run. The extractor that called it must let it unwind to its completion
43
+ * point, keep everything it collected until then, and rethrow anything else.
44
+ */
45
+ export class DeferredFailOnError extends Error {
46
+ /**
47
+ * @param {string} message Human-readable failure summary.
48
+ */
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "DeferredFailOnError";
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The extractor failures deferred so far. Populated only on introspected
57
+ * runs; the CLI reads it after the BOM and the reports are written to decide
58
+ * the process exit status.
59
+ *
60
+ * @type {Array<{ecosystem: string, tool: string, detail: string, exitCode: number|undefined, command: string|undefined, outputExcerpt: string|undefined}>}
61
+ */
62
+ const deferredFailures = [];
63
+
64
+ /**
65
+ * Test whether an error is the fail-on-error deferral signal.
66
+ *
67
+ * @param {unknown} err The caught error.
68
+ * @returns {boolean} True when the error unwinds a deferred fail-on-error abort.
69
+ */
70
+ export function isDeferredFailOnError(err) {
71
+ return err instanceof DeferredFailOnError;
72
+ }
73
+
74
+ /**
75
+ * Snapshot of the deferred failures recorded by this run.
76
+ *
77
+ * @returns {Array<Object>} Frozen copies of the recorded failures.
78
+ */
79
+ export function getDeferredFailures() {
80
+ return deferredFailures.map((failure) => Object.freeze({ ...failure }));
81
+ }
82
+
83
+ /**
84
+ * Handle a failing extractor on behalf of a `options.failOnError &&
85
+ * process.exit(1)` site. Without `fail-on-error` the run continues exactly
86
+ * as it would have. With the flag and no introspection the process exits 1,
87
+ * unchanged. On an introspected run the failure is recorded — including one
88
+ * ledger observation on the ecosystem row the failure cost evidence — and
89
+ * the deferral signal is thrown so the extractor stops where it is without
90
+ * taking any incomplete-result fallback.
91
+ *
92
+ * @param {Object} options CLI options.
93
+ * @param {Object} failure What failed, for the operator message and the report.
94
+ * @param {string} failure.tool The build tool that failed.
95
+ * @param {string} [failure.ecosystem] The ecosystem the tool serves, so the
96
+ * observation lands on the row whose evidence the failure cost.
97
+ * @param {string} failure.detail One sentence, present tense.
98
+ * @param {number} [failure.exitCode] The tool's exit status, when known.
99
+ * @param {string} [failure.command] The redacted command that was attempted.
100
+ * @param {string} [failure.outputExcerpt] The failed command's combined output,
101
+ * when the caller already holds it; the recorder bounds and redacts it.
102
+ * @returns {void} Never returns on an introspected run; it throws.
103
+ */
104
+ export function deferFailOnError(options, failure) {
105
+ if (!options?.failOnError) {
106
+ return;
107
+ }
108
+ const entry = {
109
+ ecosystem: failure?.ecosystem || "cdxgen",
110
+ tool: failure?.tool || "unknown",
111
+ detail: failure?.detail || "the extractor reported a failure",
112
+ exitCode:
113
+ typeof failure?.exitCode === "number" ? failure.exitCode : undefined,
114
+ command: failure?.command,
115
+ outputExcerpt: failure?.outputExcerpt,
116
+ };
117
+ if (!isIntrospectionEnabled(options)) {
118
+ process.exit(1);
119
+ // Unreachable in a real run; the explicit return keeps the deferral from
120
+ // continuing wherever process.exit is instrumented rather than fatal.
121
+ return;
122
+ }
123
+ deferredFailures.push(entry);
124
+ thoughtLog(
125
+ `Deferring the fail-on-error exit for ${entry.tool} so the introspection verdict can be produced: ${entry.detail}`,
126
+ );
127
+ // A `command.failed` observation on the row the failure cost evidence: it
128
+ // carries no remediationId, so it reports the deferral without competing
129
+ // with the degradation the extractor recorded for the same failure, and it
130
+ // never trips the truncation marker, which keys on `evidence.degraded`
131
+ // under the reserved recorder ecosystem.
132
+ recordLedgerEvent(LEDGER_EVENT_KINDS.COMMAND_FAILED, {
133
+ ecosystem: entry.ecosystem,
134
+ tool: entry.tool,
135
+ exitCode: entry.exitCode,
136
+ command: entry.command,
137
+ outputExcerpt: entry.outputExcerpt,
138
+ detail: `fail-on-error was deferred so the BOM and the report could be written: ${entry.detail}`,
139
+ });
140
+ throw new DeferredFailOnError(`${entry.tool}: ${entry.detail}`);
141
+ }
package/lib/core/env.js CHANGED
@@ -173,9 +173,33 @@ export function getJavaCommand() {
173
173
  /** Resolved Python executable command (PYTHON_CMD env var > CONDA_PYTHON_EXE > "python"). */
174
174
  export const PYTHON_CMD = getPythonCommand();
175
175
 
176
+ /**
177
+ * Whether an executable of the given name is reachable through PATH.
178
+ *
179
+ * @param {string} name Executable name, without a platform extension.
180
+ * @returns {boolean} True when a PATH entry holds the executable.
181
+ */
182
+ function isOnPath(name) {
183
+ const searchPath = readEnvironmentVariable("PATH");
184
+ if (!searchPath) {
185
+ return false;
186
+ }
187
+ const names =
188
+ process.platform === "win32" ? [`${name}.exe`, `${name}.bat`] : [name];
189
+ return searchPath
190
+ .split(process.platform === "win32" ? ";" : ":")
191
+ .some((binDir) =>
192
+ names.some((binName) => binDir && safeExistsSync(join(binDir, binName))),
193
+ );
194
+ }
195
+
176
196
  /**
177
197
  * Returns the Python executable command to use, resolved in priority order:
178
- * PYTHON_CMD env var > CONDA_PYTHON_EXE env var > "python".
198
+ * PYTHON_CMD env var > CONDA_PYTHON_EXE env var > "python" > "python3".
199
+ *
200
+ * Distributions that ship only a versioned interpreter leave a bare "python"
201
+ * unresolvable, which would fail every virtualenv this tool creates, so the
202
+ * versioned name is used when it is the only one PATH offers.
179
203
  *
180
204
  * @returns {string} Path or name of the Python executable
181
205
  */
@@ -185,6 +209,8 @@ export function getPythonCommand() {
185
209
  pythonCmd = readEnvironmentVariable("PYTHON_CMD");
186
210
  } else if (readEnvironmentVariable("CONDA_PYTHON_EXE")) {
187
211
  pythonCmd = readEnvironmentVariable("CONDA_PYTHON_EXE");
212
+ } else if (!isOnPath("python") && isOnPath("python3")) {
213
+ pythonCmd = "python3";
188
214
  }
189
215
  return pythonCmd;
190
216
  }
@@ -459,6 +485,39 @@ export const PACKAGE_MANAGER_ALIASES = {
459
485
  scala: ["sbt"],
460
486
  };
461
487
 
488
+ /**
489
+ * Project-type prefixes that accept a version suffix pinning a JVM build
490
+ * tool, e.g. `maven3.9.9`, `mvn3.9.9`, `gradle8.14`, `sbt1.10`, or
491
+ * `scala3.6.4`. Kept alongside the alias map because both describe how CLI
492
+ * project types map to base types.
493
+ *
494
+ * @type {string[]}
495
+ */
496
+ export const JVM_BUILD_TOOL_TYPE_PREFIXES = [
497
+ "maven",
498
+ "mvn",
499
+ "gradle",
500
+ "sbt",
501
+ "scala",
502
+ ];
503
+
504
+ /**
505
+ * Check whether a project type pins a JVM build tool version, i.e. it starts
506
+ * with one of the JVM build tool prefixes followed by a digit.
507
+ *
508
+ * @param {string} projectType Project type from the CLI
509
+ * @returns {boolean} True for versioned JVM build tool types.
510
+ */
511
+ export function isVersionedJvmToolProjectType(projectType) {
512
+ const pt = `${projectType || ""}`.toLowerCase();
513
+ for (const prefix of JVM_BUILD_TOOL_TYPE_PREFIXES) {
514
+ if (pt.startsWith(prefix) && /^\d/.test(pt.slice(prefix.length))) {
515
+ return true;
516
+ }
517
+ }
518
+ return false;
519
+ }
520
+
462
521
  /**
463
522
  * Method to check if a given feature flag is enabled.
464
523
  *
@@ -553,6 +612,15 @@ export function hasAnyProjectType(projectTypes, options, defaultStatus = true) {
553
612
  ) {
554
613
  baseProjectTypes.push("ruby");
555
614
  }
615
+ // Versioned JVM build tool types such as maven3.9.9 or gradle8.14 are java
616
+ // types with a pinned build tool, so they must dispatch java parsing too.
617
+ if (
618
+ options.projectType?.length &&
619
+ projectTypes.includes("java") &&
620
+ options.projectType.some((pt) => isVersionedJvmToolProjectType(pt))
621
+ ) {
622
+ baseProjectTypes.push("java");
623
+ }
556
624
  const baseExcludeTypes = [];
557
625
  for (const abt of Object.keys(PROJECT_TYPE_ALIASES)) {
558
626
  if (
package/lib/core/fs.js CHANGED
@@ -2,12 +2,15 @@ import { Buffer } from "node:buffer";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import {
5
+ accessSync,
5
6
  copyFileSync,
6
7
  createReadStream,
7
8
  existsSync,
9
+ constants as fsConstants,
8
10
  lstatSync,
9
11
  mkdirSync,
10
12
  mkdtempSync,
13
+ readFileSync,
11
14
  rmSync,
12
15
  unlinkSync,
13
16
  writeFileSync,
@@ -111,6 +114,26 @@ export function safeExistsSync(filePath) {
111
114
  return exists;
112
115
  }
113
116
 
117
+ /**
118
+ * Permission-aware check for whether a path exists and carries the executable
119
+ * bit for the current user. Answers false instead of throwing when the path
120
+ * is absent, unreadable, or blocked by secure mode.
121
+ *
122
+ * @param {string} filePath File path to check.
123
+ * @returns {boolean} True when the path exists and is executable.
124
+ */
125
+ export function safeIsExecutableSync(filePath) {
126
+ if (!safeExistsSync(filePath)) {
127
+ return false;
128
+ }
129
+ try {
130
+ accessSync(filePath, fsConstants.X_OK);
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
114
137
  /**
115
138
  * Permission- and dry-run-aware wrapper around writeFileSync. Records the
116
139
  * activity and returns undefined when blocked.
@@ -146,6 +169,43 @@ export function safeWriteSync(filePath, data, options) {
146
169
  recordFilesystemActivity("write", filePath, "completed");
147
170
  }
148
171
 
172
+ /**
173
+ * Permission-aware wrapper around readFileSync that returns undefined instead
174
+ * of throwing. Reads are allowed in dry-run mode, so unlike the write wrappers
175
+ * this only guards secure-mode read permission; the read itself is not recorded
176
+ * in the activity ledger because inspection of classified paths is already
177
+ * recorded by safeExistsSync and a read has no side effect to audit.
178
+ *
179
+ * @param {string} filePath File path to read.
180
+ * @param {Object|string} [options] readFileSync options; defaults to UTF-8 text.
181
+ * @returns {string|Buffer|undefined} File content, or undefined when the path cannot be read.
182
+ */
183
+ export function safeReadFileSync(filePath, options = "utf-8") {
184
+ if (!hasReadPermission(filePath)) {
185
+ if (DEBUG_MODE) {
186
+ console.log("cdxgen lacks read permission for a requested path.");
187
+ }
188
+ const pathMetadata = classifyActivityPath(filePath);
189
+ if (pathMetadata) {
190
+ recordPolicyActivity(filePath, {
191
+ metadata: {
192
+ classification: pathMetadata.classification,
193
+ ecosystem: pathMetadata.ecosystem,
194
+ policyType: "fs.read",
195
+ },
196
+ reason: `Denied read of ${pathMetadata.label} ${filePath} due to missing fs.read permission.`,
197
+ status: "blocked",
198
+ });
199
+ }
200
+ return undefined;
201
+ }
202
+ try {
203
+ return readFileSync(filePath, options);
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
149
209
  /**
150
210
  * Safely create a directory without crashing due to a lack of permissions
151
211
  *
@@ -1,17 +1,18 @@
1
1
  import { Console } from "node:console";
2
- import fs from "node:fs";
3
2
  import process from "node:process";
4
3
 
4
+ import {
5
+ closeAllSyncFileWriters,
6
+ diagnosticStream,
7
+ openSyncFileWriter,
8
+ } from "./syncFileWriter.js";
9
+
10
+ export { diagnosticStream };
11
+
5
12
  // Environment variables here are read with raw `process.env` rather than
6
13
  // `readEnvironmentVariable`: that helper lives in activity.js, which imports
7
14
  // this module, so routing these through it would form an import cycle.
8
15
 
9
- // Stream contract: human-readable diagnostics default to stderr so stdout
10
- // carries only the BOM payload. CDXGEN_LOG_STREAM=stdout restores the
11
- // pre-v13 behaviour for callers that scraped cdxgen's stdout.
12
- const logStream =
13
- process.env.CDXGEN_LOG_STREAM === "stdout" ? process.stdout : process.stderr;
14
-
15
16
  // Custom lightweight colors formatting helper replacing yoctocolors
16
17
  const colors = {
17
18
  cyanBright: (text) => `\x1b[96m${text}\x1b[39m`,
@@ -25,10 +26,10 @@ export const THINK_MODE =
25
26
  process.env.CDXGEN_DEBUG_MODE === "verbose";
26
27
 
27
28
  const thinkOutput = process.env.CDXGEN_THOUGHT_LOG
28
- ? fs.createWriteStream(process.env.CDXGEN_THOUGHT_LOG)
29
- : logStream;
29
+ ? openSyncFileWriter(process.env.CDXGEN_THOUGHT_LOG)
30
+ : diagnosticStream;
30
31
  const thinkErrorOutput = process.env.CDXGEN_THOUGHT_LOG
31
- ? fs.createWriteStream(process.env.CDXGEN_THOUGHT_LOG)
32
+ ? thinkOutput
32
33
  : process.stderr;
33
34
  const thinkLogger = new Console({
34
35
  stdout: thinkOutput,
@@ -44,10 +45,10 @@ export const TRACE_MODE =
44
45
  process.env.CDXGEN_DEBUG_MODE === "verbose";
45
46
 
46
47
  const traceOutput = process.env.CDXGEN_TRACE_LOG
47
- ? fs.createWriteStream(process.env.CDXGEN_TRACE_LOG)
48
- : logStream;
48
+ ? openSyncFileWriter(process.env.CDXGEN_TRACE_LOG)
49
+ : diagnosticStream;
49
50
  const traceErrorOutput = process.env.CDXGEN_TRACE_LOG
50
- ? fs.createWriteStream(process.env.CDXGEN_TRACE_LOG)
51
+ ? traceOutput
51
52
  : process.stderr;
52
53
  const traceLogger = new Console({
53
54
  stdout: traceOutput,
@@ -80,14 +81,18 @@ export function thoughtLog(s, args) {
80
81
  thinkLogger.log(colorizeText(`${s}`));
81
82
  }
82
83
  }
84
+ let thoughtBlockClosed = false;
85
+
83
86
  /**
84
87
  * Closes the think log group by emitting the closing `</think>` marker.
85
- * Has no effect if THINK_MODE is not enabled.
88
+ * Has no effect if THINK_MODE is not enabled, and emits the marker at most
89
+ * once so an explicit call followed by the exit handler cannot produce two.
86
90
  *
87
91
  * @returns {void}
88
92
  */
89
93
  export function thoughtEnd() {
90
- if (THINK_MODE) {
94
+ if (THINK_MODE && !thoughtBlockClosed) {
95
+ thoughtBlockClosed = true;
91
96
  thinkLogger.groupEnd();
92
97
  thinkLogger.log(colorizeText("</think>"));
93
98
  }
@@ -96,13 +101,40 @@ export function thoughtEnd() {
96
101
  function colorizeText(s) {
97
102
  // Only colorize for an interactive terminal stream; piped/CI/file output
98
103
  // stays free of escape bytes so the diagnostic stream is grep-safe.
99
- if (process.env.CDXGEN_THOUGHT_LOG || !logStream.isTTY) {
104
+ if (process.env.CDXGEN_THOUGHT_LOG || !diagnosticStream.isTTY) {
100
105
  return s;
101
106
  }
102
107
  s = s.replace(/(\d+)/g, colors.cyanBright("$1"));
103
108
  return colors.dim(s);
104
109
  }
105
110
 
111
+ // Fields serialized for each trace type. The allow-list is per-type because a
112
+ // single shared list either drops the fields a type exists to carry or leaks
113
+ // unbounded caller objects into the stream: `activity` and `phase` records
114
+ // carry no field in common with `spawn` and `http`.
115
+ const TRACE_FIELDS = {
116
+ activity: [
117
+ "identifier",
118
+ "kind",
119
+ "status",
120
+ "target",
121
+ "reason",
122
+ "networkIntent",
123
+ "command",
124
+ "cwd",
125
+ ],
126
+ phase: ["phase", "state", "detail", "note", "done", "total", "elapsedMs"],
127
+ };
128
+
129
+ const DEFAULT_TRACE_FIELDS = [
130
+ "command",
131
+ "cwd",
132
+ "protocol",
133
+ "host",
134
+ "path",
135
+ "pathname",
136
+ ];
137
+
106
138
  /**
107
139
  * Log trace messages
108
140
  *
@@ -123,15 +155,10 @@ export function traceLog(traceType, args) {
123
155
  message.type = traceType;
124
156
  }
125
157
  if (args) {
126
- for (const k of [
127
- "command",
128
- "cwd",
129
- "protocol",
130
- "host",
131
- "path",
132
- "pathname",
133
- ]) {
134
- if (args[k]) {
158
+ for (const k of TRACE_FIELDS[traceType] || DEFAULT_TRACE_FIELDS) {
159
+ // Numeric progress counters are meaningful at zero, so presence is
160
+ // tested rather than truthiness.
161
+ if (args[k] !== undefined && args[k] !== null && args[k] !== "") {
135
162
  message[k] = args[k];
136
163
  }
137
164
  }
@@ -140,3 +167,27 @@ export function traceLog(traceType, args) {
140
167
  traceLogger.log(JSON.stringify(message, null, null));
141
168
  }
142
169
  }
170
+
171
+ let logStreamsClosed = false;
172
+
173
+ /**
174
+ * Terminate the think block and close the log file descriptors.
175
+ *
176
+ * Readers tail these files as a live feed, so a run that ends without the
177
+ * closing `</think>` marker leaves the consumer holding an unterminated block.
178
+ * Registered on process exit and safe to call more than once.
179
+ *
180
+ * @returns {void}
181
+ */
182
+ export function closeLogStreams() {
183
+ if (logStreamsClosed) {
184
+ return;
185
+ }
186
+ logStreamsClosed = true;
187
+ thoughtEnd();
188
+ closeAllSyncFileWriters();
189
+ }
190
+
191
+ // The writers are synchronous, so this handler only has to emit the terminator
192
+ // and release the descriptors — work that is legal during `exit`.
193
+ process.on("exit", closeLogStreams);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared severity ordering for finding and confidence ladders.
3
+ *
4
+ * Lives in the core layer so modules below the audit layer can order labels
5
+ * such as `low`/`medium`/`high` without importing the audit package; the audit
6
+ * scoring module re-exports this constant under its historical name.
7
+ *
8
+ * @type {Readonly<Record<string, number>>}
9
+ */
10
+ export const SEVERITY_ORDER = Object.freeze({
11
+ none: -1,
12
+ low: 0,
13
+ medium: 1,
14
+ high: 2,
15
+ critical: 3,
16
+ });
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs";
2
+ import process from "node:process";
3
+ import { Writable } from "node:stream";
4
+ import { isMainThread } from "node:worker_threads";
5
+
6
+ // Stream contract: human-readable diagnostics default to stderr so stdout
7
+ // carries only the BOM payload. CDXGEN_LOG_STREAM=stdout restores the
8
+ // pre-v13 behaviour for callers that scraped cdxgen's stdout.
9
+
10
+ /**
11
+ * The stream every human-readable diagnostic is written to.
12
+ *
13
+ * Also the fallback destination of {@link openSyncFileWriter} when a log path
14
+ * cannot be opened, so an unwritable path keeps the records visible somewhere
15
+ * instead of taking the run down.
16
+ *
17
+ * @type {NodeJS.WritableStream}
18
+ */
19
+ export const diagnosticStream =
20
+ process.env.CDXGEN_LOG_STREAM === "stdout" ? process.stdout : process.stderr;
21
+
22
+ // Log files are written synchronously rather than through
23
+ // `fs.createWriteStream`. A write stream buffers, so anything still queued when
24
+ // the process exits is lost, leaving readers that tail these files a truncated
25
+ // log. Writing through one shared descriptor per path also keeps concurrent
26
+ // writers from interleaving at independent file offsets.
27
+ const syncFileWriters = new Map();
28
+
29
+ /**
30
+ * Open a synchronous writer for a log file path, or return the writer already
31
+ * open for that path.
32
+ *
33
+ * `Console` requires a real stream, so this is a `Writable` whose `_write`
34
+ * completes synchronously: bytes reach the descriptor as each record is
35
+ * written, and nothing is left queued for an event loop turn that a process
36
+ * exit may never take.
37
+ *
38
+ * @param {string} filePath Destination path
39
+ * @param {NodeJS.WritableStream|null} [fallbackStream] Stream returned when the path cannot be opened; defaults to the diagnostic stream. Pass `null` to get `undefined` instead, for callers that must never let data records spill into a diagnostic stream.
40
+ * @returns {Writable|NodeJS.WritableStream|undefined} Writer for the path, the fallback stream, or `undefined` when the path cannot be opened and no fallback was requested
41
+ */
42
+ export function openSyncFileWriter(
43
+ filePath,
44
+ fallbackStream = diagnosticStream,
45
+ ) {
46
+ const existing = syncFileWriters.get(filePath);
47
+ if (existing) {
48
+ return existing;
49
+ }
50
+ let fd;
51
+ try {
52
+ // Worker threads open the same path through their own instance of this
53
+ // module, so the descriptor is opened in append mode: O_APPEND lands every
54
+ // write at the end of the file no matter how many writers share it, where
55
+ // a truncating open gives each writer its own offset and they punch holes
56
+ // through each other's records. The main thread truncates once so a run
57
+ // still starts from an empty log.
58
+ if (isMainThread) {
59
+ try {
60
+ fs.truncateSync(filePath, 0);
61
+ } catch {
62
+ // No file yet: the append open below creates it.
63
+ }
64
+ }
65
+ fd = fs.openSync(filePath, "a");
66
+ } catch {
67
+ return fallbackStream ?? undefined;
68
+ }
69
+ const writer = new Writable({
70
+ write(chunk, _encoding, callback) {
71
+ if (fd !== undefined) {
72
+ try {
73
+ fs.writeSync(fd, chunk);
74
+ } catch {
75
+ // A full disk or a revoked descriptor must not abort BOM generation.
76
+ }
77
+ }
78
+ callback();
79
+ },
80
+ });
81
+ writer.closeSync = () => {
82
+ if (fd === undefined) {
83
+ return;
84
+ }
85
+ try {
86
+ fs.closeSync(fd);
87
+ } finally {
88
+ fd = undefined;
89
+ syncFileWriters.delete(filePath);
90
+ }
91
+ };
92
+ syncFileWriters.set(filePath, writer);
93
+ return writer;
94
+ }
95
+
96
+ /**
97
+ * Close every synchronous file writer opened by {@link openSyncFileWriter}.
98
+ * Safe to call more than once and after individual writers were closed.
99
+ *
100
+ * @returns {void}
101
+ */
102
+ export function closeAllSyncFileWriters() {
103
+ for (const writer of [...syncFileWriters.values()]) {
104
+ writer.closeSync();
105
+ }
106
+ }