@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,304 @@
1
+ /**
2
+ * Command shaping for remediation actions.
3
+ *
4
+ * The remediation catalog authors commands with template variables where the
5
+ * right value depends on the scanned project: which maven or gradle
6
+ * executable the repo wraps, which Python manager owns its lock files, which
7
+ * client npm itself should use. This module substitutes those variables from
8
+ * a facts object the caller gathered earlier, and names on every shaped
9
+ * command the detection it relied on, so a reviewer can tell a correct
10
+ * detection from a lucky default.
11
+ *
12
+ * Every function here is pure: the facts object is the only input beyond the
13
+ * template string, nothing reads the filesystem, and the same facts always
14
+ * yield the same command. The filesystem reads live in the collector
15
+ * (`commandFacts.js`).
16
+ */
17
+
18
+ /**
19
+ * Template variables this module substitutes, beside the `{{version}}` and
20
+ * `{{major}}` placeholders the version cascade fills.
21
+ *
22
+ * @type {Readonly<string[]>}
23
+ */
24
+ export const SHAPE_VARIABLES = Object.freeze([
25
+ "mvn",
26
+ "gradle",
27
+ "pythonManager",
28
+ "npmClient",
29
+ "go",
30
+ "cargo",
31
+ "composer",
32
+ "dart",
33
+ "mix",
34
+ "cabal",
35
+ ]);
36
+
37
+ /**
38
+ * Python managers a command can name, with the lock command each catalog
39
+ * entry pairs the variable with.
40
+ *
41
+ * @type {Readonly<string[]>}
42
+ */
43
+ export const PYTHON_MANAGERS = Object.freeze(["uv", "poetry", "pdm", "pipenv"]);
44
+
45
+ /**
46
+ * Project facts one shaping pass consumes. Produced once per run by the
47
+ * collector; every field is optional so a caller without collector facts
48
+ * still resolves the fallbacks.
49
+ *
50
+ * @typedef {Object} CommandFacts
51
+ * @property {string} [projectRoot] Directory the facts were gathered from.
52
+ * @property {"posix"|"windows"} [platform] Platform the command targets; defaults to posix.
53
+ * @property {Object} [wrappers] Wrapper presence facts, executable-bit included for the platform's script.
54
+ * @property {boolean} [wrappers.mvnw] ./mvnw exists and is executable.
55
+ * @property {boolean} [wrappers.mvnwInexecutable] ./mvnw exists but is not executable.
56
+ * @property {boolean} [wrappers.mvnwCmd] mvnw.cmd exists (Windows variant).
57
+ * @property {boolean} [wrappers.gradlew] ./gradlew exists and is executable.
58
+ * @property {boolean} [wrappers.gradlewInexecutable] ./gradlew exists but is not executable.
59
+ * @property {boolean} [wrappers.gradlewBat] gradlew.bat exists (Windows variant).
60
+ * @property {boolean} [wrappers.composerPhar] ./composer.phar exists and is executable.
61
+ * @property {string|undefined} [pythonManager] The Python manager in play.
62
+ * @property {string[]} [pythonManagerCandidates] Every manager detected, when two or more compete.
63
+ * @property {string} [npmClient] The npm client the project names or locks.
64
+ */
65
+
66
+ /** Wrapper executable the POSIX {{mvn}} variable resolves to. */
67
+ const MAVEN_WRAPPER_POSIX = "./mvnw";
68
+ /** Wrapper executable the Windows {{mvn}} variable resolves to. */
69
+ const MAVEN_WRAPPER_WINDOWS = "mvnw.cmd";
70
+ /** Wrapper executable the POSIX {{gradle}} variable resolves to. */
71
+ const GRADLE_WRAPPER_POSIX = "./gradlew";
72
+ /** Wrapper executable the Windows {{gradle}} variable resolves to. */
73
+ const GRADLE_WRAPPER_WINDOWS = "gradlew.bat";
74
+ /** Wrapper executable the POSIX {{composer}} variable resolves to. */
75
+ const COMPOSER_PHAR_POSIX = "./composer.phar";
76
+
77
+ /**
78
+ * The facts for the platform one command variant targets: the collector's
79
+ * platform fact describes the run, while a Windows command string needs
80
+ * Windows resolution even on a POSIX run.
81
+ *
82
+ * @param {CommandFacts} facts Gathered facts.
83
+ * @param {boolean} windows True for a Windows command variant.
84
+ * @returns {CommandFacts} Facts pinned to the requested platform.
85
+ */
86
+ function factsForPlatform(facts, windows) {
87
+ const platform = windows
88
+ ? "windows"
89
+ : facts?.platform === "windows"
90
+ ? "windows"
91
+ : "posix";
92
+ return { ...(facts || {}), platform };
93
+ }
94
+
95
+ /**
96
+ * Resolve the `{{mvn}}` variable for one platform: the project's wrapper
97
+ * when it is present and usable, the plain executable otherwise.
98
+ *
99
+ * @param {CommandFacts} facts Platform-pinned facts.
100
+ * @returns {{value: string, shapedBy: string|undefined}} Substitute and the detection behind it, when there was one.
101
+ */
102
+ function resolveMaven(facts) {
103
+ const windows = facts.platform === "windows";
104
+ const wrapper = windows ? facts?.wrappers?.mvnwCmd : facts?.wrappers?.mvnw;
105
+ if (wrapper) {
106
+ return {
107
+ value: windows ? MAVEN_WRAPPER_WINDOWS : MAVEN_WRAPPER_POSIX,
108
+ shapedBy: `wrapper:${windows ? MAVEN_WRAPPER_WINDOWS : MAVEN_WRAPPER_POSIX}`,
109
+ };
110
+ }
111
+ if (!windows && facts?.wrappers?.mvnwInexecutable) {
112
+ return {
113
+ value: "mvn",
114
+ shapedBy: `wrapper-not-executable:${MAVEN_WRAPPER_POSIX}`,
115
+ };
116
+ }
117
+ return { value: "mvn", shapedBy: undefined };
118
+ }
119
+
120
+ /**
121
+ * Resolve the `{{gradle}}` variable for one platform: the project's wrapper
122
+ * when it is present and usable, the plain executable otherwise.
123
+ *
124
+ * @param {CommandFacts} facts Platform-pinned facts.
125
+ * @returns {{value: string, shapedBy: string|undefined}} Substitute and the detection behind it, when there was one.
126
+ */
127
+ function resolveGradle(facts) {
128
+ const windows = facts.platform === "windows";
129
+ const wrapper = windows
130
+ ? facts?.wrappers?.gradlewBat
131
+ : facts?.wrappers?.gradlew;
132
+ if (wrapper) {
133
+ return {
134
+ value: windows ? GRADLE_WRAPPER_WINDOWS : GRADLE_WRAPPER_POSIX,
135
+ shapedBy: `wrapper:${windows ? GRADLE_WRAPPER_WINDOWS : GRADLE_WRAPPER_POSIX}`,
136
+ };
137
+ }
138
+ if (!windows && facts?.wrappers?.gradlewInexecutable) {
139
+ return {
140
+ value: "gradle",
141
+ shapedBy: `wrapper-not-executable:${GRADLE_WRAPPER_POSIX}`,
142
+ };
143
+ }
144
+ return { value: "gradle", shapedBy: undefined };
145
+ }
146
+
147
+ /**
148
+ * Resolve the `{{pythonManager}}` variable: the manager the collector
149
+ * detected, left unresolved when two managers compete and the project does
150
+ * not settle the question, or when none was detected.
151
+ *
152
+ * @param {CommandFacts} facts Platform-pinned facts.
153
+ * @returns {{value: string|undefined, shapedBy: string|undefined, shapedNote: string|undefined}} Substitute, the detection behind it, and the question for the agent when detection could not answer.
154
+ */
155
+ function resolvePythonManager(facts) {
156
+ const manager = facts?.pythonManager;
157
+ if (manager) {
158
+ return {
159
+ value: manager,
160
+ shapedBy: `manager:${manager}`,
161
+ shapedNote: undefined,
162
+ };
163
+ }
164
+ const candidates = Array.isArray(facts?.pythonManagerCandidates)
165
+ ? facts.pythonManagerCandidates.filter(Boolean)
166
+ : [];
167
+ if (candidates.length > 1) {
168
+ return {
169
+ value: undefined,
170
+ shapedBy: "manager:ambiguous",
171
+ shapedNote: `this project carries the lock files of ${candidates.join(" and ")}; confirm which one manages it before locking`,
172
+ };
173
+ }
174
+ return {
175
+ value: undefined,
176
+ shapedBy: "manager:unresolved",
177
+ shapedNote: undefined,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Resolve the `{{npmClient}}` variable: the client the project names or
183
+ * locks, npm when nothing says otherwise.
184
+ *
185
+ * @param {CommandFacts} facts Platform-pinned facts.
186
+ * @returns {{value: string, shapedBy: string|undefined}} Substitute and the detection behind it, when there was one.
187
+ */
188
+ function resolveNpmClient(facts) {
189
+ const client = facts?.npmClient || "npm";
190
+ return {
191
+ value: client,
192
+ shapedBy: client === "npm" ? undefined : `npm-client:${client}`,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Resolve the `{{composer}}` variable: the project's local composer.phar when
198
+ * it is present and usable, the plain executable otherwise.
199
+ *
200
+ * @param {CommandFacts} facts Platform-pinned facts.
201
+ * @returns {{value: string, shapedBy: string|undefined}} Substitute and the detection behind it, when there was one.
202
+ */
203
+ function resolveComposer(facts) {
204
+ const windows = facts.platform === "windows";
205
+ if (!windows && facts?.wrappers?.composerPhar) {
206
+ return {
207
+ value: COMPOSER_PHAR_POSIX,
208
+ shapedBy: `wrapper:${COMPOSER_PHAR_POSIX}`,
209
+ };
210
+ }
211
+ return { value: "composer", shapedBy: undefined };
212
+ }
213
+
214
+ /**
215
+ * Resolve a tool variable whose executable no wrapper or version manager in
216
+ * the facts overrides: the plain executable name.
217
+ *
218
+ * @param {string} executable The tool's executable name.
219
+ * @returns {(facts: CommandFacts) => {value: string, shapedBy: string|undefined}} Resolver for that tool's variable.
220
+ */
221
+ function resolvePlainExecutable(executable) {
222
+ return () => ({ value: executable, shapedBy: undefined });
223
+ }
224
+
225
+ const VARIABLE_RESOLVERS = {
226
+ mvn: resolveMaven,
227
+ gradle: resolveGradle,
228
+ pythonManager: resolvePythonManager,
229
+ npmClient: resolveNpmClient,
230
+ go: resolvePlainExecutable("go"),
231
+ cargo: resolvePlainExecutable("cargo"),
232
+ composer: resolveComposer,
233
+ dart: resolvePlainExecutable("dart"),
234
+ mix: resolvePlainExecutable("mix"),
235
+ cabal: resolvePlainExecutable("cabal"),
236
+ };
237
+
238
+ /**
239
+ * Substitute one template variable, leaving an unresolved `{{pythonManager}}`
240
+ * verbatim so the command stays an honest question.
241
+ *
242
+ * @param {string} command Command carrying the variable.
243
+ * @param {string} variable Variable name without braces.
244
+ * @param {CommandFacts} facts Platform-pinned facts.
245
+ * @returns {{command: string, shapedBy: string|undefined, shapedNote: string|undefined}} Command after substitution.
246
+ */
247
+ function substituteVariable(command, variable, facts) {
248
+ const placeholder = `{{${variable}}}`;
249
+ if (!command.includes(placeholder)) {
250
+ return { command, shapedBy: undefined, shapedNote: undefined };
251
+ }
252
+ const resolved = VARIABLE_RESOLVERS[variable](facts);
253
+ if (!resolved.value) {
254
+ return {
255
+ command,
256
+ shapedBy: resolved.shapedBy,
257
+ shapedNote: resolved.shapedNote,
258
+ };
259
+ }
260
+ return {
261
+ command: command.split(placeholder).join(resolved.value),
262
+ shapedBy: resolved.shapedBy,
263
+ shapedNote: resolved.shapedNote,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Shape one command string: substitute the template variables the catalog
269
+ * authors into it and report which detection drove the result. A command
270
+ * without any `{{` marker is returned byte-identical and unshaped.
271
+ *
272
+ * @param {string} templateCommand Catalog command template.
273
+ * @param {CommandFacts} [facts] Facts gathered for the scanned project.
274
+ * @returns {{command: string, shapedBy: string|undefined, shapedNote: string|undefined}} The shaped command, the detection behind it, and the question to surface when shaping could not answer.
275
+ */
276
+ export function shapeCommand(templateCommand, facts = {}) {
277
+ if (typeof templateCommand !== "string" || !templateCommand.includes("{{")) {
278
+ return {
279
+ command: templateCommand,
280
+ shapedBy: undefined,
281
+ shapedNote: undefined,
282
+ };
283
+ }
284
+ const windows = facts?.platform === "windows";
285
+ const platformFacts = factsForPlatform(facts, windows);
286
+ let command = templateCommand;
287
+ const shapedBy = new Set();
288
+ const notes = new Set();
289
+ for (const variable of SHAPE_VARIABLES) {
290
+ const result = substituteVariable(command, variable, platformFacts);
291
+ command = result.command;
292
+ if (result.shapedBy) {
293
+ shapedBy.add(result.shapedBy);
294
+ }
295
+ if (result.shapedNote) {
296
+ notes.add(result.shapedNote);
297
+ }
298
+ }
299
+ return {
300
+ command,
301
+ shapedBy: shapedBy.size ? [...shapedBy].sort().join("; ") : undefined,
302
+ shapedNote: notes.size ? [...notes].sort().join("; ") : undefined,
303
+ };
304
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Root reachability repair for the `dependencies` graph.
3
+ *
4
+ * CycloneDX expects `metadata.component` to appear in `dependencies` with the
5
+ * first level of the tree in its `dependsOn`. That edge is emitted by each
6
+ * ecosystem leg from whatever manifest declares the direct dependencies, so a
7
+ * leg that resolves a project entirely from a lock file, an export command, or
8
+ * a pre-resolved graph can produce a complete transitive tree that no consumer
9
+ * can enter: every edge is present, but nothing is reachable from the root.
10
+ *
11
+ * This module repairs that shape at finalization time, independent of the
12
+ * ecosystem. When the parent has no outgoing edge and the tree does contain
13
+ * edges, the components that nothing else points to are the roots of the graph,
14
+ * and attaching them to the parent restores reachability for the whole tree.
15
+ *
16
+ * The repair is deliberately weaker than a leg that knows the manifest: a
17
+ * direct dependency that some transitive dependency also requires is not a
18
+ * root here and stays out of the parent's `dependsOn`. It is a safety net that
19
+ * keeps a tree traversable, not a replacement for emitting the edge at the
20
+ * source, and it records what it inferred so consumers can tell the two apart.
21
+ */
22
+
23
+ import { readEnvironmentVariable } from "../../core/activity.js";
24
+ import { thoughtLog } from "../../core/logger.js";
25
+
26
+ /** Property recording how many root edges were inferred rather than observed. */
27
+ export const INFERRED_ROOTS_PROPERTY = "cdx:bom:inferredRootDependencies";
28
+
29
+ /**
30
+ * Collects the bom-refs of every component in the document, including nested
31
+ * subcomponents and the subcomponents of the parent itself.
32
+ *
33
+ * @param {Object} bomJson CycloneDX BOM
34
+ * @returns {Set<string>} Known component bom-refs
35
+ */
36
+ function collectComponentRefs(bomJson) {
37
+ const refs = new Set();
38
+ const visit = (component) => {
39
+ if (!component) {
40
+ return;
41
+ }
42
+ if (component["bom-ref"]) {
43
+ refs.add(component["bom-ref"]);
44
+ }
45
+ for (const sub of component.components || []) {
46
+ visit(sub);
47
+ }
48
+ };
49
+ for (const component of bomJson?.components || []) {
50
+ visit(component);
51
+ }
52
+ for (const sub of bomJson?.metadata?.component?.components || []) {
53
+ visit(sub);
54
+ }
55
+ for (const service of bomJson?.services || []) {
56
+ if (service?.["bom-ref"]) {
57
+ refs.add(service["bom-ref"]);
58
+ }
59
+ }
60
+ return refs;
61
+ }
62
+
63
+ /**
64
+ * Adds the missing edge from the parent component to the roots of the
65
+ * dependency graph. A no-op unless the document has a parent component with no
66
+ * outgoing edge, a tree that already contains edges, and at least one component
67
+ * that nothing points to.
68
+ *
69
+ * @param {Object} bomJson CycloneDX BOM
70
+ *
71
+ * @returns {Object} The same BOM, repaired in place when applicable
72
+ */
73
+ export function repairParentDependencyEdge(bomJson) {
74
+ if (readEnvironmentVariable("CDXGEN_PARENT_EDGE_REPAIR") === "false") {
75
+ return bomJson;
76
+ }
77
+ const dependencies = bomJson?.dependencies;
78
+ const parentRef = bomJson?.metadata?.component?.["bom-ref"];
79
+ if (!parentRef || !Array.isArray(dependencies) || !dependencies.length) {
80
+ return bomJson;
81
+ }
82
+ const lowerParentRef = parentRef.toLowerCase();
83
+ let parentEntry;
84
+ let hasEdges = false;
85
+ const referenced = new Set();
86
+ for (const adep of dependencies) {
87
+ if (!adep?.ref) {
88
+ continue;
89
+ }
90
+ if (adep.ref.toLowerCase() === lowerParentRef) {
91
+ parentEntry = adep;
92
+ }
93
+ if (adep.dependsOn?.length) {
94
+ hasEdges = true;
95
+ }
96
+ for (const ref of adep.dependsOn || []) {
97
+ referenced.add(ref);
98
+ }
99
+ // A crypto asset that is only ever provided is not a root of the tree.
100
+ for (const ref of adep.provides || []) {
101
+ referenced.add(ref);
102
+ }
103
+ }
104
+ if (parentEntry?.dependsOn?.length) {
105
+ return bomJson;
106
+ }
107
+ // Without a single edge there is no tree to enter, only a flat inventory such
108
+ // as a container image or an OS package list, where a fabricated root edge
109
+ // would assert a relationship that was never observed.
110
+ if (!hasEdges) {
111
+ return bomJson;
112
+ }
113
+ const componentRefs = collectComponentRefs(bomJson);
114
+ const roots = [];
115
+ for (const adep of dependencies) {
116
+ const ref = adep?.ref;
117
+ if (
118
+ !ref ||
119
+ ref.toLowerCase() === lowerParentRef ||
120
+ referenced.has(ref) ||
121
+ !componentRefs.has(ref)
122
+ ) {
123
+ continue;
124
+ }
125
+ roots.push(ref);
126
+ }
127
+ // Every component being a root means the observed edges all pointed at refs
128
+ // that no component in the document declares, so the graph cannot be trusted.
129
+ if (!roots.length || roots.length === componentRefs.size) {
130
+ return bomJson;
131
+ }
132
+ roots.sort();
133
+ if (parentEntry) {
134
+ parentEntry.dependsOn = roots;
135
+ } else {
136
+ dependencies.splice(0, 0, { ref: parentRef, dependsOn: roots });
137
+ }
138
+ if (!bomJson.metadata.properties) {
139
+ bomJson.metadata.properties = [];
140
+ }
141
+ const existing = bomJson.metadata.properties.find(
142
+ (p) => p.name === INFERRED_ROOTS_PROPERTY,
143
+ );
144
+ if (existing) {
145
+ existing.value = `${roots.length}`;
146
+ } else {
147
+ bomJson.metadata.properties.push({
148
+ name: INFERRED_ROOTS_PROPERTY,
149
+ value: `${roots.length}`,
150
+ });
151
+ }
152
+ thoughtLog(
153
+ `The parent component had no direct dependencies, so I attached the ${roots.length} component(s) that nothing else depends on to keep the tree reachable.`,
154
+ );
155
+ return bomJson;
156
+ }
@@ -7,10 +7,12 @@ import { Purl } from "@cdxgen/cdx-purl";
7
7
  import {
8
8
  DEBUG_MODE,
9
9
  isDryRun,
10
+ isSecureMode,
10
11
  readEnvironmentVariable,
11
12
  resetActivityContext,
12
13
  setActivityContext,
13
14
  } from "../../core/activity.js";
15
+ import { getRemediationCatalog } from "../../core/buildLedger.js";
14
16
  import { hasAnyProjectType } from "../../core/env.js";
15
17
  import {
16
18
  getTimestamp,
@@ -44,6 +46,23 @@ import { enhanceBom } from "../../inventory/licenseEnhancer.js";
44
46
  import { applyMcpPinningState } from "../../inventory/mcpPinning.js";
45
47
  import { buildReleaseNotesFromGit } from "../../inventory/source.js";
46
48
  import { extractTags, findBomType, textualMetadata } from "./annotator.js";
49
+ import {
50
+ applyFormulationRunIdMarker,
51
+ applyIntrospectionToBom,
52
+ hasIntrospectionVerdict,
53
+ } from "./introspection/annotate.js";
54
+ import { collectCommandFacts } from "./introspection/commandFacts.js";
55
+ import {
56
+ blockRemediationsForDryRun,
57
+ emitIntrospectionReports,
58
+ printIntrospectionSummary,
59
+ } from "./introspection/emit.js";
60
+ import {
61
+ isIntrospectionEnabled,
62
+ reflectOnRun,
63
+ } from "./introspection/reflect.js";
64
+ import { scoreReflection } from "./introspection/score.js";
65
+ import { repairParentDependencyEdge } from "./parentEdge.js";
47
66
  import { sortBomCollections } from "./sortBom.js";
48
67
  import {
49
68
  applyComponentTypeFilter,
@@ -435,6 +454,94 @@ function buildAuditFindingsCitation(bomJson) {
435
454
  });
436
455
  }
437
456
 
457
+ /**
458
+ * Run the build-introspection reflection over the final BOM, score it, write
459
+ * the reports, and carry the verdict inside the BOM as metadata properties and
460
+ * annotations. Introspection is opt-in; when disabled this is a no-op, and
461
+ * when enabled a reflection failure warns instead of failing the completed
462
+ * BOM.
463
+ *
464
+ * The CI gate is decided here but enforced by the CLI after the BOM is
465
+ * written, so a below-threshold score never costs the user their output.
466
+ *
467
+ * @param {Object} bomNSData BOM with namespaces object
468
+ * @param {Object} options CLI options
469
+ * @param {string} [filePath] Source path that was scanned
470
+ *
471
+ * @returns {Promise<Object>} The same bomNSData, with `reflection` (carrying its `scoring`) attached when introspection ran
472
+ */
473
+ async function applyIntrospection(bomNSData, options, filePath) {
474
+ if (!isIntrospectionEnabled(options) || !bomNSData?.bomJson) {
475
+ return bomNSData;
476
+ }
477
+ // Evidence collection re-processes a finished BOM through a fresh wrapper,
478
+ // but that pass carries neither the scanned path nor the generation's
479
+ // ecosystem context: its ledger events belong to the evinse probes and its
480
+ // marker scan finds nothing, so it would grade a project it never saw. The
481
+ // verdict describes how the BOM was generated, so it stays with the pass
482
+ // that generated it — the document itself is what carries that across.
483
+ if (bomNSData.reflection || hasIntrospectionVerdict(bomNSData.bomJson)) {
484
+ return bomNSData;
485
+ }
486
+ try {
487
+ const reflection = await reflectOnRun(bomNSData.bomJson, options, {
488
+ projectPath: filePath || options.filePath || "",
489
+ });
490
+ reflection.scoring = scoreReflection(reflection, getRemediationCatalog(), {
491
+ secureMode: isSecureMode,
492
+ inContainer: readEnvironmentVariable("CDXGEN_IN_CONTAINER") === "true",
493
+ commandFacts: collectCommandFacts(filePath || options.filePath || ""),
494
+ });
495
+ // Report consumers correlate a report with the BOM it describes through
496
+ // these two facts, so they travel on the reflection document itself.
497
+ reflection.bom = {
498
+ serialNumber: bomNSData.bomJson?.serialNumber,
499
+ componentCount: Array.isArray(bomNSData.bomJson?.components)
500
+ ? bomNSData.bomJson.components.length
501
+ : 0,
502
+ };
503
+ bomNSData.reflection = reflection;
504
+ if (isDryRun) {
505
+ blockRemediationsForDryRun(reflection.scoring);
506
+ }
507
+ // A document at a spec version that predates annotations still carries
508
+ // the metadata properties; only the annotations are version-gated.
509
+ const annotationsSupported =
510
+ normalizeCycloneDxSpecVersion(
511
+ options?.specVersion || bomNSData.bomJson?.specVersion,
512
+ ) >= 1.5;
513
+ applyFormulationRunIdMarker(bomNSData.bomJson, reflection.runId);
514
+ applyIntrospectionToBom(bomNSData.bomJson, reflection, reflection.scoring, {
515
+ annotations: annotationsSupported,
516
+ });
517
+ const delivery = emitIntrospectionReports(
518
+ reflection,
519
+ reflection.scoring,
520
+ options,
521
+ );
522
+ printIntrospectionSummary(reflection.scoring, delivery);
523
+ const threshold = options?.introspectFailBelow;
524
+ if (typeof threshold === "number" && Number.isFinite(threshold)) {
525
+ const score = reflection.scoring.overallScore;
526
+ bomNSData.introspectionGate = {
527
+ threshold,
528
+ score,
529
+ passed: score >= threshold,
530
+ };
531
+ }
532
+ if (!reflection.ledgerComplete) {
533
+ console.warn(
534
+ "Build introspection: the ledger is incomplete (buffer truncated or recorder event); treat the verdict with care and re-run with CDXGEN_INTROSPECT_LEDGER set.",
535
+ );
536
+ }
537
+ } catch (err) {
538
+ console.warn(
539
+ `Build introspection failed to complete and was skipped: ${err?.message}`,
540
+ );
541
+ }
542
+ return bomNSData;
543
+ }
544
+
438
545
  /**
439
546
  * Filter and enhance BOM post generation.
440
547
  *
@@ -442,9 +549,9 @@ function buildAuditFindingsCitation(bomJson) {
442
549
  * @param {Object} options CLI options
443
550
  * @param {string} [filePath] Source path used for formulation and metadata context
444
551
  *
445
- * @returns {Object} Modified bomNSData
552
+ * @returns {Promise<Object>} Modified bomNSData
446
553
  */
447
- export function postProcess(bomNSData, options, filePath) {
554
+ export async function postProcess(bomNSData, options, filePath) {
448
555
  let jsonPayload = bomNSData.bomJson;
449
556
  if (
450
557
  typeof bomNSData.bomJson === "string" ||
@@ -466,6 +573,9 @@ export function postProcess(bomNSData, options, filePath) {
466
573
  );
467
574
  bomNSData.bomJson = applyReleaseNotes(bomNSData.bomJson, options, filePath);
468
575
  bomNSData.bomJson = applyComponentTypeFilter(bomNSData.bomJson, options);
576
+ // Filtering can leave the tree without a way in, so this runs on the final
577
+ // component set and before annotations, which report on reachability.
578
+ bomNSData.bomJson = repairParentDependencyEdge(bomNSData.bomJson);
469
579
  bomNSData.bomJson = applySpecVersionCompatibility(bomNSData.bomJson, options);
470
580
  bomNSData.bomJson = validateTlpClassification(bomNSData.bomJson, options);
471
581
  // Support for automatic annotations
@@ -495,6 +605,9 @@ export function postProcess(bomNSData, options, filePath) {
495
605
  cleanupEnv(options);
496
606
  cleanupTmpDir();
497
607
  sortBomCollections(bomNSData.bomJson);
608
+ // Reflection reads the finished BOM, so it runs after sorting; it returns
609
+ // its verdict alongside the BOM instead of mutating it.
610
+ await applyIntrospection(bomNSData, options, filePath);
498
611
  return bomNSData;
499
612
  }
500
613
 
@@ -494,6 +494,23 @@ function registerCdxHelpers(expression, helpers = {}) {
494
494
  expression.registerFunction("formulationComponents", (bomJson) =>
495
495
  getFormulationComponents(bomJson),
496
496
  );
497
+ // Formulation-derived rules need to know whether the formulation section
498
+ // describes the run being reflected (whose probes the ledger already
499
+ // counted) or a foreign BOM's record. The caller that read the evidence
500
+ // passes its origin verdict through the options; without one, rules gated
501
+ // on a specific origin stay silent.
502
+ expression.registerFunction(
503
+ "formulationOrigin",
504
+ () => helpers?.formulationEvidence?.origin || "unknown",
505
+ );
506
+ // The toolchain mismatch a foreign BOM's formulation proves: the recorded
507
+ // version of this runtime's own family differs from the runtime in use, so
508
+ // the record describes another machine. Computed by the caller, which owns
509
+ // the runtime facts.
510
+ expression.registerFunction(
511
+ "formulationRuntimeMismatch",
512
+ () => helpers?.formulationEvidence?.runtimeMismatch,
513
+ );
497
514
  return expression;
498
515
  }
499
516