@cdxgen/cdxgen 13.0.1 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +26 -13
  2. package/bin/audit.js +6 -0
  3. package/bin/cdxgen.js +125 -4
  4. package/bin/evinse.js +1 -1
  5. package/data/component-tags.json +8 -1
  6. package/data/frameworks-list.json +86 -1
  7. package/data/remediations.json +1144 -0
  8. package/data/rules/asar-archives.yaml +1 -1
  9. package/data/rules/build-fidelity.yaml +782 -0
  10. package/data/rules/package-integrity.yaml +13 -13
  11. package/lib/audit/index.js +48 -4
  12. package/lib/audit/scoring.js +2 -8
  13. package/lib/cli/bomAssembly.js +20 -1
  14. package/lib/cli/cliOptions.js +94 -7
  15. package/lib/cli/index.js +76 -48
  16. package/lib/cli/jsBom.js +32 -3
  17. package/lib/cli/jvmBom.js +1494 -1315
  18. package/lib/cli/managedBom.js +97 -28
  19. package/lib/cli/nativeBom.js +189 -10
  20. package/lib/core/buildLedger.js +799 -0
  21. package/lib/core/deferredExit.js +141 -0
  22. package/lib/core/env.js +69 -1
  23. package/lib/core/fs.js +60 -0
  24. package/lib/core/logger.js +12 -99
  25. package/lib/core/severity.js +16 -0
  26. package/lib/core/syncFileWriter.js +106 -0
  27. package/lib/ecosystems/ecosystems.js +9 -0
  28. package/lib/ecosystems/gradleutils.js +355 -15
  29. package/lib/ecosystems/parsers-js.js +26 -0
  30. package/lib/ecosystems/parsers-jvm.js +9 -0
  31. package/lib/ecosystems/parsers-misc.js +16 -2
  32. package/lib/ecosystems/parsers-python.js +171 -35
  33. package/lib/ecosystems/parsers-rust.js +12 -2
  34. package/lib/ecosystems/pypiClassifiers.js +326 -0
  35. package/lib/ecosystems/pythonutils.js +49 -1
  36. package/lib/ecosystems/rubyutils.js +13 -0
  37. package/lib/evinser/evinser.js +30 -12
  38. package/lib/helpers/remote/dependency-track.js +81 -14
  39. package/lib/inventory/auditCategories.js +9 -0
  40. package/lib/inventory/depsUtils.js +72 -0
  41. package/lib/inventory/dosai.js +431 -22
  42. package/lib/inventory/envcontext.js +206 -18
  43. package/lib/inventory/fetchBatch.js +57 -0
  44. package/lib/inventory/jvmToolEnv.js +593 -0
  45. package/lib/inventory/osinfo.js +1 -0
  46. package/lib/inventory/toolRequirements.js +903 -0
  47. package/lib/managers/binary.js +24 -0
  48. package/lib/managers/docker.js +26 -5
  49. package/lib/server/server.js +1 -1
  50. package/lib/stages/postgen/annotationBuilder.js +97 -0
  51. package/lib/stages/postgen/auditBom.js +76 -79
  52. package/lib/stages/postgen/introspection/annotate.js +320 -0
  53. package/lib/stages/postgen/introspection/commandFacts.js +293 -0
  54. package/lib/stages/postgen/introspection/emit.js +192 -0
  55. package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
  56. package/lib/stages/postgen/introspection/markers.js +227 -0
  57. package/lib/stages/postgen/introspection/reflect.js +1214 -0
  58. package/lib/stages/postgen/introspection/report.js +1208 -0
  59. package/lib/stages/postgen/introspection/score.js +1067 -0
  60. package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
  61. package/lib/stages/postgen/parentEdge.js +156 -0
  62. package/lib/stages/postgen/postgen.js +115 -2
  63. package/lib/stages/postgen/ruleEngine.js +17 -0
  64. package/lib/stages/pregen/envAudit.js +42 -2
  65. package/lib/stages/pregen/pregen.js +1171 -6
  66. package/lib/validator/bomValidator.js +11 -2
  67. package/package.json +14 -14
  68. package/types/lib/audit/index.d.ts.map +1 -1
  69. package/types/lib/audit/scoring.d.ts +2 -8
  70. package/types/lib/audit/scoring.d.ts.map +1 -1
  71. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  72. package/types/lib/cli/cliOptions.d.ts +2 -1
  73. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  74. package/types/lib/cli/index.d.ts +8 -0
  75. package/types/lib/cli/index.d.ts.map +1 -1
  76. package/types/lib/cli/jsBom.d.ts.map +1 -1
  77. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  78. package/types/lib/cli/managedBom.d.ts.map +1 -1
  79. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  80. package/types/lib/core/buildLedger.d.ts +272 -0
  81. package/types/lib/core/buildLedger.d.ts.map +1 -0
  82. package/types/lib/core/deferredExit.d.ts +84 -0
  83. package/types/lib/core/deferredExit.d.ts.map +1 -0
  84. package/types/lib/core/env.d.ts +22 -1
  85. package/types/lib/core/env.d.ts.map +1 -1
  86. package/types/lib/core/fs.d.ts +21 -0
  87. package/types/lib/core/fs.d.ts.map +1 -1
  88. package/types/lib/core/logger.d.ts +2 -11
  89. package/types/lib/core/logger.d.ts.map +1 -1
  90. package/types/lib/core/severity.d.ts +11 -0
  91. package/types/lib/core/severity.d.ts.map +1 -0
  92. package/types/lib/core/syncFileWriter.d.ts +33 -0
  93. package/types/lib/core/syncFileWriter.d.ts.map +1 -0
  94. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  95. package/types/lib/ecosystems/gradleutils.d.ts +33 -0
  96. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  97. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  98. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  99. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  100. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  101. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  102. package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
  103. package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
  104. package/types/lib/ecosystems/pythonutils.d.ts +20 -0
  105. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  106. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  107. package/types/lib/evinser/evinser.d.ts +5 -1
  108. package/types/lib/evinser/evinser.d.ts.map +1 -1
  109. package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
  110. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  111. package/types/lib/inventory/auditCategories.d.ts +8 -0
  112. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  113. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  114. package/types/lib/inventory/dosai.d.ts +20 -0
  115. package/types/lib/inventory/dosai.d.ts.map +1 -1
  116. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  117. package/types/lib/inventory/fetchBatch.d.ts +9 -0
  118. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  119. package/types/lib/inventory/jvmToolEnv.d.ts +189 -0
  120. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
  121. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  122. package/types/lib/inventory/toolRequirements.d.ts +216 -0
  123. package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
  124. package/types/lib/managers/binary.d.ts.map +1 -1
  125. package/types/lib/managers/docker.d.ts.map +1 -1
  126. package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
  127. package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
  128. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  129. package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
  130. package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
  131. package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
  132. package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
  133. package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
  134. package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
  135. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
  136. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
  137. package/types/lib/stages/postgen/introspection/markers.d.ts +57 -0
  138. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
  139. package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
  140. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
  141. package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
  142. package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
  143. package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
  144. package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
  145. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
  146. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
  147. package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
  148. package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
  149. package/types/lib/stages/postgen/postgen.d.ts +2 -2
  150. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  151. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  152. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  153. package/types/lib/stages/pregen/pregen.d.ts +131 -0
  154. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  155. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Report emission for build introspection: resolving report destinations,
3
+ * writing the markdown and JSON reports, and printing the console summary.
4
+ *
5
+ * This module is the only place in the introspection chain that touches the
6
+ * filesystem or prints, which keeps every renderer above it pure and
7
+ * testable against committed fixtures. All writes go through the safe
8
+ * wrappers, and dry-run mode produces the report without writing anything.
9
+ */
10
+
11
+ import { dirname } from "node:path";
12
+
13
+ import { isDryRun } from "../../../core/activity.js";
14
+ import {
15
+ safeExistsSync,
16
+ safeMkdirSync,
17
+ safeWriteSync,
18
+ } from "../../../core/fs.js";
19
+ import { diagnosticStream } from "../../../core/syncFileWriter.js";
20
+ import {
21
+ buildIntrospectionJson,
22
+ renderIntrospectionConsole,
23
+ renderIntrospectionMarkdown,
24
+ } from "./report.js";
25
+
26
+ /**
27
+ * Report destination meaning "write this report to the diagnostic stream".
28
+ *
29
+ * @type {string}
30
+ */
31
+ export const INTROSPECTION_STDERR_TARGET = "-";
32
+
33
+ /** Default markdown report name when the run has no output file (`--print`). */
34
+ const DEFAULT_REPORT_NAME = "cdxgen-introspection.md";
35
+ /** Default JSON report name when the run has no output file (`--print`). */
36
+ const DEFAULT_JSON_NAME = "cdxgen-introspection.json";
37
+
38
+ /**
39
+ * Resolve the report destinations for a run. A user-provided path wins; the
40
+ * defaults are derived from the BOM output path so the reports travel with
41
+ * the BOM, falling back to the working directory when the BOM goes to stdout.
42
+ *
43
+ * @param {Object} [options] CLI options.
44
+ * @param {string} [options.output] Path the BOM is written to, or "-" for stdout.
45
+ * @param {string} [options.introspectReport] Requested markdown report path.
46
+ * @param {string} [options.introspectJson] Requested JSON report path.
47
+ * @returns {{reportPath: string, jsonPath: string}} Resolved destinations; "-" means the diagnostic stream.
48
+ */
49
+ export function resolveIntrospectionReportPaths(options = {}) {
50
+ const output =
51
+ typeof options?.output === "string" &&
52
+ options.output &&
53
+ options.output !== INTROSPECTION_STDERR_TARGET
54
+ ? options.output
55
+ : undefined;
56
+ const reportPath =
57
+ options?.introspectReport ||
58
+ (output ? `${output}.introspection.md` : DEFAULT_REPORT_NAME);
59
+ const jsonPath =
60
+ options?.introspectJson ||
61
+ (output ? `${output}.introspection.json` : DEFAULT_JSON_NAME);
62
+ return { reportPath, jsonPath };
63
+ }
64
+
65
+ /** Last summary printed in this process, so a repeated verdict stays quiet. */
66
+ let lastPrintedSummary;
67
+
68
+ /**
69
+ * Serialize the JSON report the way it is written to disk: stable key order
70
+ * comes from the renderer, two-space indentation from the document convention.
71
+ *
72
+ * @param {Object} report Report document from buildIntrospectionJson.
73
+ * @returns {string} Serialized report.
74
+ */
75
+ function serializeReport(report) {
76
+ return `${JSON.stringify(report, null, 2)}\n`;
77
+ }
78
+
79
+ /**
80
+ * Write text to a path, creating the parent directory when needed.
81
+ *
82
+ * @param {string} filePath Destination path.
83
+ * @param {string} content File content.
84
+ * @returns {void}
85
+ */
86
+ function writeReportFile(filePath, content) {
87
+ const parentDir = dirname(filePath);
88
+ if (parentDir && !safeExistsSync(parentDir)) {
89
+ safeMkdirSync(parentDir, { recursive: true });
90
+ }
91
+ safeWriteSync(filePath, content);
92
+ }
93
+
94
+ /**
95
+ * Deliver one report to its destination: the diagnostic stream for "-", the
96
+ * named file otherwise. Returns the destination actually used so the console
97
+ * summary names it.
98
+ *
99
+ * @param {string} target Requested destination.
100
+ * @param {string} content Report text.
101
+ * @returns {string} The destination the report reached.
102
+ */
103
+ function deliverReport(target, content) {
104
+ if (target === INTROSPECTION_STDERR_TARGET) {
105
+ diagnosticStream.write(content);
106
+ return target;
107
+ }
108
+ writeReportFile(target, content);
109
+ return target;
110
+ }
111
+
112
+ /**
113
+ * Mark every remediation in a scoring document blocked because a dry-run
114
+ * records intent without executing anything: no fix the report proposes can
115
+ * be applied by this run, so the loop must not try.
116
+ *
117
+ * @param {Object} scored Scoring document from scoreReflection, mutated in place.
118
+ * @returns {void}
119
+ */
120
+ export function blockRemediationsForDryRun(scored) {
121
+ const remediations = Array.isArray(scored?.remediations)
122
+ ? scored.remediations
123
+ : [];
124
+ for (const entry of remediations) {
125
+ if (entry?.blocked === true) {
126
+ continue;
127
+ }
128
+ entry.blocked = true;
129
+ entry.blockedReason =
130
+ "policy.dry-run: dry-run mode records intent without executing commands, so no fix can be applied by this run";
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Write the markdown and JSON reports for a completed reflection and return
136
+ * the destinations they reached. Dry-run mode prints the markdown report to
137
+ * the diagnostic stream instead of creating files, matching the read-only
138
+ * contract of the mode.
139
+ *
140
+ * @param {Object} reflection Reflection document from reflectOnRun.
141
+ * @param {Object} scored Scoring document from scoreReflection.
142
+ * @param {Object} [options] CLI options naming the report destinations.
143
+ * @returns {{reportTarget: string|undefined, jsonTarget: string|undefined, dryRun: boolean}} Destinations reached; undefined means the report was not produced as a file.
144
+ */
145
+ export function emitIntrospectionReports(reflection, scored, options = {}) {
146
+ const { reportPath, jsonPath } = resolveIntrospectionReportPaths(options);
147
+ const markdown = renderIntrospectionMarkdown(reflection, scored, options);
148
+ if (isDryRun) {
149
+ diagnosticStream.write(`${markdown}\n`);
150
+ return { reportTarget: undefined, jsonTarget: undefined, dryRun: true };
151
+ }
152
+ const reportTarget = deliverReport(reportPath, markdown);
153
+ const jsonTarget = deliverReport(
154
+ jsonPath,
155
+ serializeReport(buildIntrospectionJson(reflection, scored, options)),
156
+ );
157
+ return { reportTarget, jsonTarget, dryRun: false };
158
+ }
159
+
160
+ /**
161
+ * Print the console summary for a completed introspection. Called after the
162
+ * reports are delivered, so every path it names exists (or is the explicit
163
+ * stream marker). Diagnostics go to the diagnostic stream so a piped stdout
164
+ * never carries anything but the BOM payload.
165
+ *
166
+ * @param {Object} scored Scoring document from scoreReflection.
167
+ * @param {Object} [delivery] Destinations returned by emitIntrospectionReports.
168
+ * @returns {void}
169
+ */
170
+ export function printIntrospectionSummary(scored, delivery = {}) {
171
+ const lines = [
172
+ renderIntrospectionConsole(scored, {
173
+ introspectReport: delivery.reportTarget,
174
+ introspectJson: delivery.jsonTarget,
175
+ }),
176
+ ];
177
+ if (delivery.dryRun) {
178
+ lines.push(
179
+ "Build introspection: dry-run mode produced the report above without writing any file\n",
180
+ );
181
+ }
182
+ const summary = lines.join("");
183
+ // Evidence collection re-enters postProcess with an enriched BOM, so a run
184
+ // reflects more than once and the later verdict supersedes the earlier one.
185
+ // The reports are rewritten each time; the summary is printed only when it
186
+ // says something the reader has not already been told.
187
+ if (summary === lastPrintedSummary) {
188
+ return;
189
+ }
190
+ lastPrintedSummary = summary;
191
+ diagnosticStream.write(summary);
192
+ }
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Reader over the BOM's formulation section, the third evidence source.
3
+ *
4
+ * Formulation is two things at once, and the distinction governs every
5
+ * confidence decision downstream. The run-derived half — `type: "platform"`
6
+ * components recording the toolchain the generating run probed, and the git
7
+ * provenance components — is what cdxgen itself did. The config-parsed half —
8
+ * the commands, environment variables and task types under
9
+ * `formulation[].workflows[]` — is what the repo's CI declares, and a
10
+ * declared command was never observed to run.
11
+ *
12
+ * This module is pure: the BOM and the caller's run facts are the only
13
+ * inputs, nothing is read from the filesystem, and environment variable
14
+ * *values* are never touched — names only, because the BOM stores them and
15
+ * copying them into a report would republish them.
16
+ */
17
+
18
+ /**
19
+ * Property carrying the id of the run that stamped a BOM's formulation, set
20
+ * on every formulation workflow by the introspection pass that reflected
21
+ * over the freshly generated document.
22
+ *
23
+ * @type {string}
24
+ */
25
+ export const FORMULATION_RUN_ID_PROPERTY = "cdx:introspection:runId";
26
+
27
+ /** The formulation record was produced by the run being reflected. */
28
+ export const FORMULATION_ORIGIN_SAME_RUN = "same-run";
29
+
30
+ /** The formulation record came with a BOM this run did not generate. */
31
+ export const FORMULATION_ORIGIN_FOREIGN = "foreign";
32
+
33
+ /**
34
+ * The BOM carries no formulation record at all, which is neither of the
35
+ * above: there is no evidence to weigh, so every consumer must behave as it
36
+ * did before formulation became a source.
37
+ */
38
+ export const FORMULATION_ORIGIN_ABSENT = "absent";
39
+
40
+ /**
41
+ * Platform component names, as the environment probes emit them, mapped to
42
+ * the canonical ecosystem whose row their facts can corroborate. Names not
43
+ * listed here have no fidelity row to join.
44
+ *
45
+ * @type {Readonly<Record<string, string>>}
46
+ */
47
+ export const TOOL_NAME_ECOSYSTEMS = Object.freeze({
48
+ java: "java",
49
+ dotnet: "csharp",
50
+ python: "python",
51
+ "Node.js": "npm",
52
+ gcc: "c",
53
+ rustc: "rust",
54
+ go: "go",
55
+ ruby: "ruby",
56
+ swift: "swift",
57
+ });
58
+
59
+ /**
60
+ * Resolver executables whose presence in a declared command says a build or
61
+ * dependency resolution was attempted, mapped to the ecosystem they drive.
62
+ * Keys are executable basenames, lower-cased.
63
+ *
64
+ * @type {Readonly<Record<string, string>>}
65
+ */
66
+ export const RESOLVER_EXECUTABLES = Object.freeze({
67
+ mvn: "java",
68
+ mvnw: "java",
69
+ gradle: "java",
70
+ gradlew: "java",
71
+ java: "java",
72
+ ant: "java",
73
+ sbt: "java",
74
+ npm: "npm",
75
+ pnpm: "npm",
76
+ yarn: "npm",
77
+ bun: "npm",
78
+ deno: "npm",
79
+ npx: "npm",
80
+ node: "npm",
81
+ pip: "python",
82
+ pip3: "python",
83
+ poetry: "python",
84
+ uv: "python",
85
+ pdm: "python",
86
+ pipenv: "python",
87
+ conda: "python",
88
+ python: "python",
89
+ python3: "python",
90
+ cargo: "rust",
91
+ go: "go",
92
+ bundle: "ruby",
93
+ gem: "ruby",
94
+ rake: "ruby",
95
+ ruby: "ruby",
96
+ dotnet: "csharp",
97
+ nuget: "csharp",
98
+ composer: "php",
99
+ dart: "dart",
100
+ flutter: "dart",
101
+ helm: "helm",
102
+ lein: "clojure",
103
+ clj: "clojure",
104
+ clojure: "clojure",
105
+ swift: "swift",
106
+ pod: "cocoa",
107
+ mix: "elixir",
108
+ cmake: "c",
109
+ make: "c",
110
+ meson: "c",
111
+ ninja: "c",
112
+ cabal: "haskell",
113
+ stack: "haskell",
114
+ });
115
+
116
+ /**
117
+ * The runtime names the reflection reports, mapped to the family a
118
+ * formulation platform component records for the same runtime.
119
+ *
120
+ * @type {Readonly<Record<string, string>>}
121
+ */
122
+ const RUNTIME_FAMILIES = Object.freeze({
123
+ "Node.js": "Node.js",
124
+ node: "Node.js",
125
+ Deno: "Deno",
126
+ deno: "Deno",
127
+ Bun: "Bun",
128
+ bun: "Bun",
129
+ });
130
+
131
+ /**
132
+ * The final path segment of a POSIX- or Windows-flavoured command token.
133
+ *
134
+ * @param {string} token Command token.
135
+ * @returns {string} The token after its last path separator.
136
+ */
137
+ function basenameOfToken(token) {
138
+ const slash = token.lastIndexOf("/");
139
+ const backslash = token.lastIndexOf("\\");
140
+ return token.slice(Math.max(slash, backslash) + 1);
141
+ }
142
+
143
+ /**
144
+ * Whether an `executed` value is a CI action reference such as
145
+ * `actions/checkout@<sha>` rather than a shell command. An action reference
146
+ * carries no whitespace, contains a path separator, and names a revision of
147
+ * the repository after the last separator; a relative-path executable such
148
+ * as `./mvnw` carries no revision and stays a command.
149
+ *
150
+ * @param {string|undefined} executed The executed value of a step command.
151
+ * @returns {boolean} True when the value names an action, not a command.
152
+ */
153
+ export function isActionReference(executed) {
154
+ if (typeof executed !== "string" || !executed.trim()) {
155
+ return false;
156
+ }
157
+ const text = executed.trim();
158
+ if (/\s/.test(text) || !text.includes("/")) {
159
+ return false;
160
+ }
161
+ const lastSegment = text.slice(text.lastIndexOf("/") + 1);
162
+ return lastSegment.includes("@");
163
+ }
164
+
165
+ /**
166
+ * The executable a command line starts with, as a bare name.
167
+ *
168
+ * @param {string|undefined} commandLine Command line.
169
+ * @returns {string} Basename of the first whitespace token, or "" when absent.
170
+ */
171
+ export function executableOf(commandLine) {
172
+ const first = `${commandLine || ""}`.trim().split(/\s+/)[0] || "";
173
+ return basenameOfToken(first);
174
+ }
175
+
176
+ /**
177
+ * The revision a git provenance component records, from its OmniBOR or
178
+ * Software Heritage identifier. Identifiers prefix the raw hash with a
179
+ * scheme and type, so the value after the last colon is the revision itself.
180
+ *
181
+ * @param {Object} component Formulation component of type `file`.
182
+ * @returns {string|undefined} The raw revision, when the component names one.
183
+ */
184
+ function revisionOf(component) {
185
+ const identifiers = [
186
+ ...(Array.isArray(component?.omniborId) ? component.omniborId : []),
187
+ ...(Array.isArray(component?.swhid) ? component.swhid : []),
188
+ ];
189
+ for (const identifier of identifiers) {
190
+ if (typeof identifier === "string" && identifier.includes(":")) {
191
+ return identifier.slice(identifier.lastIndexOf(":") + 1) || undefined;
192
+ }
193
+ }
194
+ return undefined;
195
+ }
196
+
197
+ /**
198
+ * Whether a formulation component is one of the git graph nodes the
199
+ * provenance builder adds beside the per-file blobs.
200
+ *
201
+ * @param {Object} component Formulation component.
202
+ * @returns {boolean} True for `git-parent` and `git-tree`.
203
+ */
204
+ function isGitGraphNode(component) {
205
+ return component?.name === "git-parent" || component?.name === "git-tree";
206
+ }
207
+
208
+ /**
209
+ * The run id a formulation workflow carries, when the introspection pass
210
+ * that reflected over it stamped one.
211
+ *
212
+ * @param {Object} workflow Formulation workflow.
213
+ * @returns {string|undefined} The stamped run id, when present.
214
+ */
215
+ function stampedRunId(workflow) {
216
+ for (const property of Array.isArray(workflow?.properties)
217
+ ? workflow.properties
218
+ : []) {
219
+ if (property?.name === FORMULATION_RUN_ID_PROPERTY) {
220
+ return property?.value;
221
+ }
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ /**
227
+ * Whether a formulation workflow carries this run's id marker.
228
+ *
229
+ * @param {Object} bomJson CycloneDX BOM.
230
+ * @param {string} runId The reflection's own run id.
231
+ * @returns {boolean} True when the formulation names this run.
232
+ */
233
+ function carriesRunIdMarker(bomJson, runId) {
234
+ if (!runId) {
235
+ return false;
236
+ }
237
+ for (const entry of Array.isArray(bomJson?.formulation)
238
+ ? bomJson.formulation
239
+ : []) {
240
+ for (const workflow of Array.isArray(entry?.workflows)
241
+ ? entry.workflows
242
+ : []) {
243
+ if (stampedRunId(workflow) === runId) {
244
+ return true;
245
+ }
246
+ }
247
+ }
248
+ return false;
249
+ }
250
+
251
+ /**
252
+ * Read the provenance-aware formulation evidence out of a BOM.
253
+ *
254
+ * `origin` decides how the evidence may be used. `same-run` means the
255
+ * formulation describes this very run — its platform components are the same
256
+ * probes the ledger recorded as `tool.resolved`, so the evidence corroborates
257
+ * and must never add confidence. `foreign` means the BOM arrived without a
258
+ * ledger for its generation, and the formulation is the only surviving trace
259
+ * of what that build attempted. `absent` is neither: the BOM carries no
260
+ * formulation record, so there is nothing to weigh and every consumer keeps
261
+ * the behaviour it had before formulation became a source.
262
+ *
263
+ * Environment variable names are collected; values are never read.
264
+ *
265
+ * @param {Object} bomJson CycloneDX BOM.
266
+ * @param {Object} [context] Run facts the caller already knows.
267
+ * @param {string} [context.runId] This reflection's run id, matched against the formulation's marker.
268
+ * @param {number} [context.ledgerEventCount] Events the ledger holds for this BOM's generation.
269
+ * @returns {{
270
+ * origin: "same-run"|"foreign"|"absent",
271
+ * tools: {name: string|undefined, version: string|undefined, source: "probe"}[],
272
+ * declaredCommands: {executable: string, commandLine: string, workflow: string|undefined, task: string|undefined, step: string|undefined, source: "ci-config"}[],
273
+ * sourceTree: {commit: string|undefined, treeId: string|undefined, fileCount: number}|undefined,
274
+ * environmentKeys: string[]
275
+ * }} The formulation evidence.
276
+ */
277
+ export function readFormulationEvidence(bomJson, context = {}) {
278
+ const entries = Array.isArray(bomJson?.formulation)
279
+ ? bomJson.formulation
280
+ : [];
281
+ const sameRun =
282
+ Number(context.ledgerEventCount) > 0 ||
283
+ carriesRunIdMarker(bomJson, context.runId);
284
+ const tools = [];
285
+ let commit;
286
+ let treeId;
287
+ let fileCount = 0;
288
+ const environmentKeys = [];
289
+ const declaredCommands = [];
290
+ for (const entry of entries) {
291
+ for (const component of Array.isArray(entry?.components)
292
+ ? entry.components
293
+ : []) {
294
+ if (component?.type === "platform") {
295
+ tools.push({
296
+ name: component.name,
297
+ version: component.version,
298
+ source: "probe",
299
+ });
300
+ } else if (component?.type === "file") {
301
+ if (isGitGraphNode(component)) {
302
+ if (component.name === "git-parent") {
303
+ commit = revisionOf(component) || commit;
304
+ } else {
305
+ treeId = revisionOf(component) || treeId;
306
+ }
307
+ } else {
308
+ fileCount += 1;
309
+ }
310
+ }
311
+ }
312
+ for (const workflow of Array.isArray(entry?.workflows)
313
+ ? entry.workflows
314
+ : []) {
315
+ for (const input of Array.isArray(workflow?.inputs)
316
+ ? workflow.inputs
317
+ : []) {
318
+ for (const variable of Array.isArray(input?.environmentVars)
319
+ ? input.environmentVars
320
+ : []) {
321
+ if (variable?.name && !environmentKeys.includes(variable.name)) {
322
+ environmentKeys.push(variable.name);
323
+ }
324
+ }
325
+ }
326
+ for (const task of Array.isArray(workflow?.tasks) ? workflow.tasks : []) {
327
+ for (const step of Array.isArray(task?.steps) ? task.steps : []) {
328
+ for (const command of Array.isArray(step?.commands)
329
+ ? step.commands
330
+ : []) {
331
+ const executed = command?.executed;
332
+ if (
333
+ typeof executed !== "string" ||
334
+ !executed.trim() ||
335
+ isActionReference(executed)
336
+ ) {
337
+ continue;
338
+ }
339
+ declaredCommands.push({
340
+ executable: executableOf(executed),
341
+ commandLine: executed.trim(),
342
+ workflow: workflow?.name || workflow?.uid,
343
+ task: task?.name || task?.uid,
344
+ step: step?.name,
345
+ source: "ci-config",
346
+ });
347
+ }
348
+ }
349
+ }
350
+ }
351
+ }
352
+ return {
353
+ origin: !entries.length
354
+ ? FORMULATION_ORIGIN_ABSENT
355
+ : sameRun
356
+ ? FORMULATION_ORIGIN_SAME_RUN
357
+ : FORMULATION_ORIGIN_FOREIGN,
358
+ tools,
359
+ declaredCommands,
360
+ sourceTree:
361
+ commit || treeId || fileCount > 0
362
+ ? { commit, treeId, fileCount }
363
+ : undefined,
364
+ environmentKeys,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * The formulation platform entry for the runtime family this reflection runs
370
+ * on, when the record cannot describe the machine in use: either the
371
+ * recorded version of the family differs from the runtime actually running,
372
+ * or the record names other toolchains but no entry of this family at all. A
373
+ * difference proves the formulation's toolchain record describes a machine
374
+ * other than the one re-scanning the BOM; it says nothing about the other
375
+ * toolchains the record names, which no probe of this run observed.
376
+ *
377
+ * @param {Object} evidence Formulation evidence from readFormulationEvidence.
378
+ * @param {Object} runtime The reflection's runtime facts.
379
+ * @param {string} runtime.name Runtime name, such as "Node.js".
380
+ * @param {string} runtime.version Runtime version.
381
+ * @returns {{tool: string, recordedVersion: string|undefined, runtimeVersion: string}|undefined} The mismatching formulation entry, when one exists.
382
+ */
383
+ export function findRuntimeToolMismatch(evidence, runtime) {
384
+ const family = RUNTIME_FAMILIES[`${runtime?.name}`];
385
+ const tools = Array.isArray(evidence?.tools) ? evidence.tools : [];
386
+ if (!family || !runtime?.version || !tools.length) {
387
+ return undefined;
388
+ }
389
+ for (const tool of tools) {
390
+ if (tool?.name === family) {
391
+ if (tool.version !== runtime.version) {
392
+ return {
393
+ tool: family,
394
+ recordedVersion: tool.version,
395
+ runtimeVersion: runtime.version,
396
+ };
397
+ }
398
+ return undefined;
399
+ }
400
+ }
401
+ return {
402
+ tool: family,
403
+ recordedVersion: undefined,
404
+ runtimeVersion: runtime.version,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * The declared CI command a formulation records for one ecosystem, if any.
410
+ * Commands carry no ecosystem tag, so the executable answers: the first
411
+ * declared command whose executable drives that ecosystem's resolver wins,
412
+ * in the order the formulation records them.
413
+ *
414
+ * @param {Object} evidence Formulation evidence from readFormulationEvidence.
415
+ * @param {string} ecosystem Canonical ecosystem name.
416
+ * @returns {Object|undefined} The matching declared command entry.
417
+ */
418
+ export function declaredCommandForEcosystem(evidence, ecosystem) {
419
+ for (const command of Array.isArray(evidence?.declaredCommands)
420
+ ? evidence.declaredCommands
421
+ : []) {
422
+ if (RESOLVER_EXECUTABLES[command.executable] === ecosystem) {
423
+ return command;
424
+ }
425
+ }
426
+ return undefined;
427
+ }