@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.
- package/README.md +26 -13
- package/bin/audit.js +6 -0
- package/bin/cdxgen.js +125 -4
- package/bin/evinse.js +1 -1
- package/data/component-tags.json +8 -1
- package/data/frameworks-list.json +86 -1
- package/data/remediations.json +1144 -0
- package/data/rules/asar-archives.yaml +1 -1
- package/data/rules/build-fidelity.yaml +782 -0
- package/data/rules/package-integrity.yaml +13 -13
- package/lib/audit/index.js +48 -4
- package/lib/audit/scoring.js +2 -8
- package/lib/cli/bomAssembly.js +20 -1
- package/lib/cli/cliOptions.js +94 -7
- package/lib/cli/index.js +76 -48
- package/lib/cli/jsBom.js +32 -3
- package/lib/cli/jvmBom.js +1494 -1315
- package/lib/cli/managedBom.js +97 -28
- package/lib/cli/nativeBom.js +189 -10
- package/lib/core/buildLedger.js +799 -0
- package/lib/core/deferredExit.js +141 -0
- package/lib/core/env.js +69 -1
- package/lib/core/fs.js +60 -0
- package/lib/core/logger.js +12 -99
- package/lib/core/severity.js +16 -0
- package/lib/core/syncFileWriter.js +106 -0
- package/lib/ecosystems/ecosystems.js +9 -0
- package/lib/ecosystems/gradleutils.js +355 -15
- package/lib/ecosystems/parsers-js.js +26 -0
- package/lib/ecosystems/parsers-jvm.js +9 -0
- package/lib/ecosystems/parsers-misc.js +16 -2
- package/lib/ecosystems/parsers-python.js +171 -35
- package/lib/ecosystems/parsers-rust.js +12 -2
- package/lib/ecosystems/pypiClassifiers.js +326 -0
- package/lib/ecosystems/pythonutils.js +49 -1
- package/lib/ecosystems/rubyutils.js +13 -0
- package/lib/evinser/evinser.js +30 -12
- package/lib/helpers/remote/dependency-track.js +81 -14
- package/lib/inventory/auditCategories.js +9 -0
- package/lib/inventory/depsUtils.js +72 -0
- package/lib/inventory/dosai.js +431 -22
- package/lib/inventory/envcontext.js +206 -18
- package/lib/inventory/fetchBatch.js +57 -0
- package/lib/inventory/jvmToolEnv.js +593 -0
- package/lib/inventory/osinfo.js +1 -0
- package/lib/inventory/toolRequirements.js +903 -0
- package/lib/managers/binary.js +24 -0
- package/lib/managers/docker.js +26 -5
- package/lib/server/server.js +1 -1
- package/lib/stages/postgen/annotationBuilder.js +97 -0
- package/lib/stages/postgen/auditBom.js +76 -79
- package/lib/stages/postgen/introspection/annotate.js +320 -0
- package/lib/stages/postgen/introspection/commandFacts.js +293 -0
- package/lib/stages/postgen/introspection/emit.js +192 -0
- package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
- package/lib/stages/postgen/introspection/markers.js +227 -0
- package/lib/stages/postgen/introspection/reflect.js +1214 -0
- package/lib/stages/postgen/introspection/report.js +1208 -0
- package/lib/stages/postgen/introspection/score.js +1067 -0
- package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
- package/lib/stages/postgen/parentEdge.js +156 -0
- package/lib/stages/postgen/postgen.js +115 -2
- package/lib/stages/postgen/ruleEngine.js +17 -0
- package/lib/stages/pregen/envAudit.js +42 -2
- package/lib/stages/pregen/pregen.js +1171 -6
- package/lib/validator/bomValidator.js +11 -2
- package/package.json +14 -14
- package/types/lib/audit/index.d.ts.map +1 -1
- package/types/lib/audit/scoring.d.ts +2 -8
- package/types/lib/audit/scoring.d.ts.map +1 -1
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +2 -1
- package/types/lib/cli/cliOptions.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts +8 -0
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/buildLedger.d.ts +272 -0
- package/types/lib/core/buildLedger.d.ts.map +1 -0
- package/types/lib/core/deferredExit.d.ts +84 -0
- package/types/lib/core/deferredExit.d.ts.map +1 -0
- package/types/lib/core/env.d.ts +22 -1
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts +21 -0
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/logger.d.ts +2 -11
- package/types/lib/core/logger.d.ts.map +1 -1
- package/types/lib/core/severity.d.ts +11 -0
- package/types/lib/core/severity.d.ts.map +1 -0
- package/types/lib/core/syncFileWriter.d.ts +33 -0
- package/types/lib/core/syncFileWriter.d.ts.map +1 -0
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +33 -0
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
- package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
- package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
- package/types/lib/ecosystems/pythonutils.d.ts +20 -0
- package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/evinser/evinser.d.ts +5 -1
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
- package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
- package/types/lib/inventory/auditCategories.d.ts +8 -0
- package/types/lib/inventory/auditCategories.d.ts.map +1 -1
- package/types/lib/inventory/depsUtils.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +20 -0
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/envcontext.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts +9 -0
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/jvmToolEnv.d.ts +189 -0
- package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
- package/types/lib/inventory/osinfo.d.ts.map +1 -1
- package/types/lib/inventory/toolRequirements.d.ts +216 -0
- package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
- package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
- package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
- package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
- package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
- package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
- package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
- package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/markers.d.ts +57 -0
- package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
- package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
- package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
- package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
- package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
- package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
- package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
- package/types/lib/stages/postgen/postgen.d.ts +2 -2
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
- package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
- package/types/lib/stages/pregen/pregen.d.ts +131 -0
- package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderers over a completed reflection document and its scoring document.
|
|
3
|
+
*
|
|
4
|
+
* Every function here is pure: the reflection and scoring documents are the
|
|
5
|
+
* only evidence inputs, nothing reads the filesystem, the ledger, or the BOM
|
|
6
|
+
* again, and nothing writes a file. The caller owns serialization and printing,
|
|
7
|
+
* which is what makes these renderable against a committed fixture without a
|
|
8
|
+
* temporary directory.
|
|
9
|
+
*
|
|
10
|
+
* The markdown report is written for a human reading it in a terminal pager, a
|
|
11
|
+
* CI log, or a pull-request comment: plain GitHub-flavoured markdown, no HTML,
|
|
12
|
+
* no emoji, and a verdict line that carries the right impression on its own.
|
|
13
|
+
* The JSON report is written for the remediation loop: a versioned contract
|
|
14
|
+
* with stable key order and no content that churns between identical runs, so
|
|
15
|
+
* an agent can diff consecutive reports to detect progress. Free text is
|
|
16
|
+
* redacted again here even though the ledger already sanitized it, because
|
|
17
|
+
* this report is the artifact most likely to be pasted into a chat window.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { redactIntrospectionText } from "../../../core/buildLedger.js";
|
|
21
|
+
import { SEVERITY_ORDER } from "../../../core/severity.js";
|
|
22
|
+
import { TIER_LADDER } from "./score.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Version of the JSON report contract. Additive fields bump the minor version;
|
|
26
|
+
* removals or semantic changes bump the major version.
|
|
27
|
+
*
|
|
28
|
+
* @type {string}
|
|
29
|
+
*/
|
|
30
|
+
export const INTROSPECTION_REPORT_SCHEMA_VERSION = "1.1";
|
|
31
|
+
|
|
32
|
+
/** Longest free-text span kept whole inside a markdown table cell before an
|
|
33
|
+
* explicit ellipsis marker is appended. */
|
|
34
|
+
const CELL_LIMIT = 80;
|
|
35
|
+
|
|
36
|
+
/** Longest rule-finding prose kept whole in the evidence section. */
|
|
37
|
+
const PROSE_LIMIT = 200;
|
|
38
|
+
|
|
39
|
+
/** Marker appended by the cell and prose truncators. */
|
|
40
|
+
const ELLIPSIS = "…";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One-line theme for a tier, phrased as what the reader is missing. Rendered
|
|
44
|
+
* in the verdict line, so a reader who stops there still knows what is wrong.
|
|
45
|
+
*
|
|
46
|
+
* @type {Readonly<Record<string, string>>}
|
|
47
|
+
*/
|
|
48
|
+
const TIER_THEMES = Object.freeze({
|
|
49
|
+
lockfile:
|
|
50
|
+
"the SBOM for {ecosystems} captures pinned versions but not a fully resolved dependency graph",
|
|
51
|
+
manifest: "the SBOM is missing transitive dependencies for {ecosystems}",
|
|
52
|
+
heuristic:
|
|
53
|
+
"components for {ecosystems} were inferred from build artifacts rather than a manifest",
|
|
54
|
+
absent: "no components were produced for {ecosystems}",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Degradation-impact phrase used as the "why" of a ledger-derived remediation.
|
|
59
|
+
*
|
|
60
|
+
* @type {Readonly<Record<string, string>>}
|
|
61
|
+
*/
|
|
62
|
+
const IMPACT_WHY = Object.freeze({
|
|
63
|
+
"transitive-deps": "transitive dependency evidence was lost",
|
|
64
|
+
versions: "version precision was lost",
|
|
65
|
+
integrity: "integrity evidence was lost",
|
|
66
|
+
components: "component coverage was lost",
|
|
67
|
+
licenses: "license data was lost",
|
|
68
|
+
none: "evidence was degraded without a measurable component cost",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Action kinds whose command is a real, copy-pasteable shell command. */
|
|
72
|
+
const COMMAND_KINDS = new Set(["install", "build", "container"]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Redact a free-text value before it reaches a report. The ledger already
|
|
76
|
+
* sanitized its own fields; this is the report-side assertion of the same
|
|
77
|
+
* guarantee over every string the renderers emit, through the very same
|
|
78
|
+
* redactor the ledger uses — never a second, weaker one.
|
|
79
|
+
*
|
|
80
|
+
* @param {string|undefined} value Free text of untrusted origin.
|
|
81
|
+
* @returns {string|undefined} Redacted text.
|
|
82
|
+
*/
|
|
83
|
+
function redacted(value) {
|
|
84
|
+
return redactIntrospectionText(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sort a copy of a list by one or more string accessors, so no renderer
|
|
89
|
+
* iterates an array whose order depends on how the input was assembled.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object[]} values Array to sort.
|
|
92
|
+
* @param {((value: Object) => string)[]} accessors Ordered sort keys.
|
|
93
|
+
* @returns {Object[]} Sorted copy.
|
|
94
|
+
*/
|
|
95
|
+
function sortedBy(values, ...accessors) {
|
|
96
|
+
return [...(Array.isArray(values) ? values : [])].sort((a, b) => {
|
|
97
|
+
for (const accessor of accessors) {
|
|
98
|
+
const order = `${accessor(a) || ""}`.localeCompare(
|
|
99
|
+
`${accessor(b) || ""}`,
|
|
100
|
+
);
|
|
101
|
+
if (order !== 0) {
|
|
102
|
+
return order;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return 0;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The final path segment of a POSIX or Windows path, used to shorten marker
|
|
111
|
+
* paths in report prose where the directory prefix adds nothing.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} value Opaque path.
|
|
114
|
+
* @returns {string} Basename.
|
|
115
|
+
*/
|
|
116
|
+
function basenameOf(value) {
|
|
117
|
+
const text = `${value ?? ""}`;
|
|
118
|
+
const slashIndex = text.lastIndexOf("/");
|
|
119
|
+
const backslashIndex = text.lastIndexOf("\\");
|
|
120
|
+
return text.slice(Math.max(slashIndex, backslashIndex) + 1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Scored rows in ecosystem order, independent of how the scoring document was
|
|
125
|
+
* assembled.
|
|
126
|
+
*
|
|
127
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
128
|
+
* @returns {Object[]} Scored ecosystem rows sorted by name.
|
|
129
|
+
*/
|
|
130
|
+
function scoredRows(scored) {
|
|
131
|
+
return sortedBy(scored?.ecosystems, (row) => row?.ecosystem);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Derive the overall tier and confidence labels from the scored rows.
|
|
136
|
+
*
|
|
137
|
+
* The scoring document publishes a number only. Both labels the reports
|
|
138
|
+
* publish are the *worst* among the scored rows: one unhealthy ecosystem must
|
|
139
|
+
* not disappear behind a healthy average, and no report may claim more
|
|
140
|
+
* confidence than its least-corroborated row. A reflection with no scored
|
|
141
|
+
* rows carries no verdict at all, so both labels are null.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
144
|
+
* @returns {{tier: string|null, confidence: string|null}} Worst tier and confidence among the scored rows.
|
|
145
|
+
*/
|
|
146
|
+
export function overallAssessment(scored) {
|
|
147
|
+
let tier = null;
|
|
148
|
+
let tierRank = -1;
|
|
149
|
+
let confidence = null;
|
|
150
|
+
let confidenceRank = Number.POSITIVE_INFINITY;
|
|
151
|
+
for (const row of scoredRows(scored)) {
|
|
152
|
+
const rank = TIER_LADDER.indexOf(row?.tier);
|
|
153
|
+
if (rank > tierRank) {
|
|
154
|
+
tierRank = rank;
|
|
155
|
+
tier = row?.tier;
|
|
156
|
+
}
|
|
157
|
+
const rankOfConfidence = SEVERITY_ORDER[row?.confidence];
|
|
158
|
+
if (
|
|
159
|
+
typeof rankOfConfidence === "number" &&
|
|
160
|
+
rankOfConfidence < confidenceRank
|
|
161
|
+
) {
|
|
162
|
+
confidenceRank = rankOfConfidence;
|
|
163
|
+
confidence = row?.confidence;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { tier, confidence };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Escape and bound a value for use inside a markdown table cell: pipes and
|
|
171
|
+
* the backslash that escapes them are both escaped so the table cannot break
|
|
172
|
+
* and a Windows path renders as written, newlines collapse, and long values end
|
|
173
|
+
* with an explicit ellipsis marker rather than being silently cut.
|
|
174
|
+
*
|
|
175
|
+
* @param {string|undefined} value Cell text.
|
|
176
|
+
* @param {number} [limit] Length kept whole before truncation.
|
|
177
|
+
* @returns {string} Cell-safe text.
|
|
178
|
+
*/
|
|
179
|
+
function mdCell(value, limit = CELL_LIMIT) {
|
|
180
|
+
let text = `${value ?? ""}`.replace(/\r?\n/g, " ").replace(/[\\|]/g, "\\$&");
|
|
181
|
+
if (text.length > limit) {
|
|
182
|
+
text = `${text.slice(0, limit - 1)}${ELLIPSIS}`;
|
|
183
|
+
}
|
|
184
|
+
return text;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Escape and bound prose for a markdown list item. Unlike table cells the
|
|
189
|
+
* pipe needs no escaping, but long prose is cut at the same explicit marker.
|
|
190
|
+
*
|
|
191
|
+
* @param {string|undefined} value Prose text.
|
|
192
|
+
* @param {number} [limit] Length kept whole before truncation.
|
|
193
|
+
* @returns {string} List-safe text.
|
|
194
|
+
*/
|
|
195
|
+
function mdProse(value, limit = PROSE_LIMIT) {
|
|
196
|
+
let text = `${value ?? ""}`.replace(/\r?\n/g, " ");
|
|
197
|
+
if (text.length > limit) {
|
|
198
|
+
text = `${text.slice(0, limit - 1)}${ELLIPSIS}`;
|
|
199
|
+
}
|
|
200
|
+
return redacted(text) ?? "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One shell-safe command line for a fenced block: newlines collapse and the
|
|
205
|
+
* value is redacted, but the line is never truncated because a cut command is
|
|
206
|
+
* not copy-pasteable.
|
|
207
|
+
*
|
|
208
|
+
* @param {string|undefined} value Command text.
|
|
209
|
+
* @returns {string} Block-safe command line.
|
|
210
|
+
*/
|
|
211
|
+
function codeLine(value) {
|
|
212
|
+
return `${redacted(value) ?? ""}`.replace(/\r?\n/g, " ").trim();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A fenced code block whose fence is longer than any backtick run inside the
|
|
217
|
+
* content, so build output that itself contains fenced blocks cannot close
|
|
218
|
+
* the fence early. Interior newlines survive; the content is redacted as a
|
|
219
|
+
* whole so a multi-line value is treated exactly as the ledger treated it.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} info Fence info string.
|
|
222
|
+
* @param {string|undefined} content Block content.
|
|
223
|
+
* @returns {string[]} The block's lines.
|
|
224
|
+
*/
|
|
225
|
+
function fencedBlock(info, content) {
|
|
226
|
+
const text = `${redacted(content) ?? ""}`;
|
|
227
|
+
let longestRun = 0;
|
|
228
|
+
for (const match of text.matchAll(/`+/g)) {
|
|
229
|
+
longestRun = Math.max(longestRun, match[0].length);
|
|
230
|
+
}
|
|
231
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
232
|
+
return [`${fence}${info}`, ...text.split("\n"), fence];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The evidence lines of one remediation's failure facts: the failed
|
|
237
|
+
* command with its exit code, the recorded cause, and — for a declared
|
|
238
|
+
* command that arrived with a foreign BOM's formulation — the command named
|
|
239
|
+
* as a hypothesis, in phrasing that can never read as something that ran.
|
|
240
|
+
*
|
|
241
|
+
* @param {Object} entry Remediation entry.
|
|
242
|
+
* @returns {string[]} Evidence lines, empty when the entry carries none.
|
|
243
|
+
*/
|
|
244
|
+
function evidenceLines(entry) {
|
|
245
|
+
const evidence = entry?.evidence;
|
|
246
|
+
if (!evidence) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
const lines = [];
|
|
250
|
+
if (evidence.failedCommand) {
|
|
251
|
+
const exitCode =
|
|
252
|
+
typeof evidence.exitCode === "number"
|
|
253
|
+
? ` (exit code ${evidence.exitCode})`
|
|
254
|
+
: "";
|
|
255
|
+
lines.push(`- Failed: \`${codeLine(evidence.failedCommand)}\`${exitCode}`);
|
|
256
|
+
}
|
|
257
|
+
if (evidence.attemptedCommand) {
|
|
258
|
+
lines.push(
|
|
259
|
+
`- Declared by the BOM's formulation, not observed to have run: \`${codeLine(evidence.attemptedCommand)}\``,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (evidence.cause) {
|
|
263
|
+
lines.push(`- Cause: ${mdProse(evidence.cause)}`);
|
|
264
|
+
}
|
|
265
|
+
return lines;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The output excerpt block of one remediation, rendered below the command
|
|
270
|
+
* blocks: reference material a reader diagnoses from, never a step to run.
|
|
271
|
+
*
|
|
272
|
+
* @param {Object} entry Remediation entry.
|
|
273
|
+
* @returns {string[]} Block lines, empty when the run recorded no excerpt.
|
|
274
|
+
*/
|
|
275
|
+
function excerptLines(entry) {
|
|
276
|
+
const excerpt = entry?.evidence?.outputExcerpt;
|
|
277
|
+
if (!excerpt) {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
return [
|
|
281
|
+
"Failed command output (bounded to its last 2000 characters, redacted):",
|
|
282
|
+
"",
|
|
283
|
+
...fencedBlock("text", excerpt),
|
|
284
|
+
"",
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Rebuild the cdxgen invocation a report describes. The same shape backs the
|
|
290
|
+
* Reproduce section and the re-scan commands proposed to the reader, so every
|
|
291
|
+
* command block in the report can be pasted verbatim.
|
|
292
|
+
*
|
|
293
|
+
* @param {Object} reflection Reflection document.
|
|
294
|
+
* @param {Object} options CLI options.
|
|
295
|
+
* @param {boolean} [options.installDeps] Whether the invocation installs dependencies.
|
|
296
|
+
* @param {string} [options.output] BOM output path.
|
|
297
|
+
* @returns {string} The invocation.
|
|
298
|
+
*/
|
|
299
|
+
function cdxgenInvocation(reflection, options) {
|
|
300
|
+
const parts = ["cdxgen"];
|
|
301
|
+
const projectTypes = Array.isArray(reflection?.projectTypes)
|
|
302
|
+
? reflection.projectTypes
|
|
303
|
+
: [];
|
|
304
|
+
if (projectTypes.length) {
|
|
305
|
+
parts.push("-t", projectTypes.join(","));
|
|
306
|
+
}
|
|
307
|
+
parts.push(
|
|
308
|
+
options?.installDeps === false ? "--no-install-deps" : "--install-deps",
|
|
309
|
+
);
|
|
310
|
+
if (options?.output) {
|
|
311
|
+
parts.push("-o", `${options.output}`);
|
|
312
|
+
}
|
|
313
|
+
if (reflection?.projectPath) {
|
|
314
|
+
parts.push(`${reflection.projectPath}`);
|
|
315
|
+
}
|
|
316
|
+
return parts.join(" ");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The facts of the scanned BOM the report correlates itself with. Absent
|
|
321
|
+
* facts are dropped rather than emitted as nulls, so consumers can detect
|
|
322
|
+
* what the caller actually knew.
|
|
323
|
+
*
|
|
324
|
+
* @param {Object} reflection Reflection document.
|
|
325
|
+
* @param {Object} options CLI options.
|
|
326
|
+
* @returns {Object|undefined} The bom section.
|
|
327
|
+
*/
|
|
328
|
+
function bomSection(reflection, options) {
|
|
329
|
+
const section = {};
|
|
330
|
+
if (reflection?.bom?.serialNumber) {
|
|
331
|
+
section.serialNumber = reflection.bom.serialNumber;
|
|
332
|
+
}
|
|
333
|
+
if (Number.isFinite(reflection?.bom?.componentCount)) {
|
|
334
|
+
section.componentCount = reflection.bom.componentCount;
|
|
335
|
+
}
|
|
336
|
+
if (options?.output) {
|
|
337
|
+
section.path = `${options.output}`;
|
|
338
|
+
}
|
|
339
|
+
return Object.keys(section).length ? section : undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The CI gate result. The gate exists only when the caller configured a
|
|
344
|
+
* threshold; without one there is nothing to publish.
|
|
345
|
+
*
|
|
346
|
+
* @param {Object} scored Scoring document.
|
|
347
|
+
* @param {Object} options CLI options.
|
|
348
|
+
* @returns {Object|undefined} The gate section.
|
|
349
|
+
*/
|
|
350
|
+
function gateSection(scored, options) {
|
|
351
|
+
const threshold = options?.introspectFailBelow;
|
|
352
|
+
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
const score =
|
|
356
|
+
typeof scored?.overallScore === "number" ? scored.overallScore : 100;
|
|
357
|
+
return { threshold, passed: score >= threshold };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Tool fact entries in a fixed key order with their free-text fields redacted:
|
|
362
|
+
* a wanted or found value is whatever the project's own configuration declared,
|
|
363
|
+
* so it reaches the report through the same guarantee as every other string.
|
|
364
|
+
*
|
|
365
|
+
* @param {Object[]} entries Sorted tool entries.
|
|
366
|
+
* @returns {Object[]} The JSON tool entries.
|
|
367
|
+
*/
|
|
368
|
+
function toolEntriesJson(entries) {
|
|
369
|
+
return entries.map((entry) => ({
|
|
370
|
+
tool: entry?.tool,
|
|
371
|
+
source: entry?.source,
|
|
372
|
+
wanted: redacted(entry?.wanted),
|
|
373
|
+
found: redacted(entry?.found),
|
|
374
|
+
path: redacted(entry?.path),
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* One JSON ecosystem row: the scoring facts joined with the reflection row's
|
|
380
|
+
* tier reasons and tool facts, built in a fixed key order so consecutive
|
|
381
|
+
* reports diff cleanly.
|
|
382
|
+
*
|
|
383
|
+
* @param {Object} row Scored ecosystem row.
|
|
384
|
+
* @param {Object|undefined} reflectionRow Matching reflection row.
|
|
385
|
+
* @returns {Object} The JSON ecosystem entry.
|
|
386
|
+
*/
|
|
387
|
+
function ecosystemJson(row, reflectionRow) {
|
|
388
|
+
const source = reflectionRow || {};
|
|
389
|
+
return {
|
|
390
|
+
ecosystem: row?.ecosystem,
|
|
391
|
+
state: row?.state,
|
|
392
|
+
tier: row?.tier,
|
|
393
|
+
ceilingTier: row?.ceilingTier,
|
|
394
|
+
score: row?.score,
|
|
395
|
+
confidence: row?.confidence,
|
|
396
|
+
componentCount: row?.componentCount,
|
|
397
|
+
dependencyEdgeCount: row?.dependencyEdgeCount,
|
|
398
|
+
tierReasons: sortedBy(
|
|
399
|
+
source?.tierReasons,
|
|
400
|
+
(reason) => (reason?.determining ? "" : "1"),
|
|
401
|
+
(reason) => reason?.source,
|
|
402
|
+
(reason) => reason?.id,
|
|
403
|
+
).map((reason) => ({
|
|
404
|
+
source: reason?.source,
|
|
405
|
+
id: reason?.id,
|
|
406
|
+
detail: redacted(reason?.detail),
|
|
407
|
+
determining: reason?.determining === true,
|
|
408
|
+
})),
|
|
409
|
+
tools: {
|
|
410
|
+
expected: toolEntriesJson(
|
|
411
|
+
sortedBy(
|
|
412
|
+
source?.toolsExpected,
|
|
413
|
+
(entry) => entry?.tool,
|
|
414
|
+
(entry) => entry?.source,
|
|
415
|
+
(entry) => entry?.wanted,
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
resolved: toolEntriesJson(
|
|
419
|
+
sortedBy(
|
|
420
|
+
source?.toolsResolved,
|
|
421
|
+
(entry) => entry?.tool,
|
|
422
|
+
(entry) => entry?.source,
|
|
423
|
+
(entry) => entry?.found,
|
|
424
|
+
),
|
|
425
|
+
),
|
|
426
|
+
missing: toolEntriesJson(
|
|
427
|
+
sortedBy(
|
|
428
|
+
source?.toolsMissing,
|
|
429
|
+
(entry) => entry?.tool,
|
|
430
|
+
(entry) => entry?.source,
|
|
431
|
+
(entry) => entry?.wanted,
|
|
432
|
+
),
|
|
433
|
+
),
|
|
434
|
+
mismatched: toolEntriesJson(
|
|
435
|
+
sortedBy(
|
|
436
|
+
source?.toolsMismatched,
|
|
437
|
+
(entry) => entry?.tool,
|
|
438
|
+
(entry) => entry?.wanted,
|
|
439
|
+
),
|
|
440
|
+
),
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The evidence block of one JSON remediation record: the failure facts the
|
|
447
|
+
* run recorded, rebuilt in a fixed key order with the free text re-redacted.
|
|
448
|
+
* Absent fields are omitted, never null, and the whole block is omitted when
|
|
449
|
+
* the run recorded no failure facts — rule-derived entries carry none.
|
|
450
|
+
*
|
|
451
|
+
* @param {Object|undefined} evidence Evidence facts from the scoring entry.
|
|
452
|
+
* @returns {Object|undefined} The JSON evidence block.
|
|
453
|
+
*/
|
|
454
|
+
function evidenceJson(evidence) {
|
|
455
|
+
if (!evidence || typeof evidence !== "object") {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
const block = {};
|
|
459
|
+
if (evidence.failedCommand) {
|
|
460
|
+
block.failedCommand = redacted(evidence.failedCommand);
|
|
461
|
+
}
|
|
462
|
+
if (evidence.attemptedCommand) {
|
|
463
|
+
block.attemptedCommand = redacted(evidence.attemptedCommand);
|
|
464
|
+
}
|
|
465
|
+
if (evidence.attemptedCommandSource) {
|
|
466
|
+
block.attemptedCommandSource = evidence.attemptedCommandSource;
|
|
467
|
+
}
|
|
468
|
+
if (typeof evidence.exitCode === "number") {
|
|
469
|
+
block.exitCode = evidence.exitCode;
|
|
470
|
+
}
|
|
471
|
+
if (evidence.cause) {
|
|
472
|
+
block.cause = redacted(evidence.cause);
|
|
473
|
+
}
|
|
474
|
+
if (evidence.outputExcerpt) {
|
|
475
|
+
block.outputExcerpt = redacted(evidence.outputExcerpt);
|
|
476
|
+
}
|
|
477
|
+
return Object.keys(block).length ? block : undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* One JSON remediation record: the scoring document's entry rebuilt in a
|
|
482
|
+
* fixed key order with free text re-redacted.
|
|
483
|
+
*
|
|
484
|
+
* @param {Object} entry Ranked remediation entry.
|
|
485
|
+
* @returns {Object} The JSON remediation record.
|
|
486
|
+
*/
|
|
487
|
+
function remediationJson(entry) {
|
|
488
|
+
return {
|
|
489
|
+
remediationId: entry?.remediationId,
|
|
490
|
+
source: entry?.source,
|
|
491
|
+
ecosystem: entry?.ecosystem,
|
|
492
|
+
confidence: entry?.confidence,
|
|
493
|
+
severity: entry?.severity,
|
|
494
|
+
summary: redacted(entry?.summary),
|
|
495
|
+
guidance: entry?.guidance ? redacted(entry.guidance) : undefined,
|
|
496
|
+
impact: entry?.impact,
|
|
497
|
+
targetTier: entry?.targetTier,
|
|
498
|
+
currentScore: entry?.currentScore,
|
|
499
|
+
projectedScore: entry?.projectedScore,
|
|
500
|
+
expectedGain: entry?.expectedGain,
|
|
501
|
+
evidenceCount: entry?.evidenceCount,
|
|
502
|
+
evidence: evidenceJson(entry?.evidence),
|
|
503
|
+
subsumes: Array.isArray(entry?.subsumes) ? entry.subsumes : undefined,
|
|
504
|
+
// Actions keep the catalog's authored sequence — install, then build,
|
|
505
|
+
// then re-run — because the order is the recipe the loop follows.
|
|
506
|
+
actions: (Array.isArray(entry?.actions) ? entry.actions : []).map(
|
|
507
|
+
(action) => ({
|
|
508
|
+
kind: action?.kind,
|
|
509
|
+
tool: action?.tool,
|
|
510
|
+
via: action?.via,
|
|
511
|
+
image: action?.image,
|
|
512
|
+
shapedBy: action?.shapedBy ? redacted(action.shapedBy) : undefined,
|
|
513
|
+
versionFrom: action?.versionFrom,
|
|
514
|
+
versionSource: action?.versionSource
|
|
515
|
+
? redacted(action.versionSource)
|
|
516
|
+
: undefined,
|
|
517
|
+
versionSourceMissing:
|
|
518
|
+
action?.versionSourceMissing === true ? true : undefined,
|
|
519
|
+
command: redacted(action?.command),
|
|
520
|
+
windows: redacted(action?.windows),
|
|
521
|
+
windowsReason: action?.windowsReason
|
|
522
|
+
? redacted(action.windowsReason)
|
|
523
|
+
: undefined,
|
|
524
|
+
}),
|
|
525
|
+
),
|
|
526
|
+
verify: entry?.verify
|
|
527
|
+
? {
|
|
528
|
+
rules: entry.verify.rules
|
|
529
|
+
? [...entry.verify.rules].sort()
|
|
530
|
+
: undefined,
|
|
531
|
+
expectTier: entry.verify.expectTier,
|
|
532
|
+
eventsCleared: entry.verify.eventsCleared
|
|
533
|
+
? [...entry.verify.eventsCleared].sort()
|
|
534
|
+
: undefined,
|
|
535
|
+
}
|
|
536
|
+
: undefined,
|
|
537
|
+
docs: entry?.docs,
|
|
538
|
+
blocked: entry?.blocked === true,
|
|
539
|
+
blockedReason: entry?.blockedReason
|
|
540
|
+
? redacted(entry.blockedReason)
|
|
541
|
+
: undefined,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Build the JSON report document, the remediation loop's contract.
|
|
547
|
+
*
|
|
548
|
+
* @param {Object} reflection Reflection document from reflectOnRun.
|
|
549
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
550
|
+
* @param {Object} [options] CLI options that name the run's outputs.
|
|
551
|
+
* @param {string} [options.output] Path the BOM was written to.
|
|
552
|
+
* @param {number} [options.introspectFailBelow] CI gate threshold, when configured.
|
|
553
|
+
* @returns {Object} The report document; serializing it is the caller's job.
|
|
554
|
+
*/
|
|
555
|
+
export function buildIntrospectionJson(reflection, scored, options = {}) {
|
|
556
|
+
const assessment = overallAssessment(scored);
|
|
557
|
+
const rowsByEcosystem = new Map(
|
|
558
|
+
(Array.isArray(reflection?.ecosystems) ? reflection.ecosystems : []).map(
|
|
559
|
+
(row) => [row?.ecosystem, row],
|
|
560
|
+
),
|
|
561
|
+
);
|
|
562
|
+
return {
|
|
563
|
+
schemaVersion: INTROSPECTION_REPORT_SCHEMA_VERSION,
|
|
564
|
+
runId: reflection?.runId,
|
|
565
|
+
inputsFingerprint: reflection?.inputsFingerprint
|
|
566
|
+
? `sha256:${reflection.inputsFingerprint}`
|
|
567
|
+
: undefined,
|
|
568
|
+
generatedAt: reflection?.generatedAt,
|
|
569
|
+
cdxgen: {
|
|
570
|
+
version: reflection?.cdxgenVersion,
|
|
571
|
+
runtime: {
|
|
572
|
+
name: reflection?.runtime?.name,
|
|
573
|
+
version: reflection?.runtime?.version,
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
bom: bomSection(reflection, options),
|
|
577
|
+
ledger: {
|
|
578
|
+
source: reflection?.ledgerSource,
|
|
579
|
+
complete: reflection?.ledgerComplete !== false,
|
|
580
|
+
eventCount: reflection?.ledgerEventCount ?? 0,
|
|
581
|
+
truncated: reflection?.ledgerComplete === false,
|
|
582
|
+
},
|
|
583
|
+
overall: {
|
|
584
|
+
score: scored?.overallScore,
|
|
585
|
+
tier: assessment.tier,
|
|
586
|
+
confidence: assessment.confidence,
|
|
587
|
+
},
|
|
588
|
+
ecosystems: scoredRows(scored).map((row) =>
|
|
589
|
+
ecosystemJson(row, rowsByEcosystem.get(row?.ecosystem)),
|
|
590
|
+
),
|
|
591
|
+
coverageGaps: sortedBy(scored?.unsupported, (gap) => gap?.ecosystem).map(
|
|
592
|
+
(gap) => ({
|
|
593
|
+
ecosystem: gap?.ecosystem,
|
|
594
|
+
// Marker basenames only: the report carries no absolute paths beyond
|
|
595
|
+
// the BOM path and the resolved tool paths.
|
|
596
|
+
markers: (Array.isArray(gap?.markersOnDisk)
|
|
597
|
+
? gap.markersOnDisk
|
|
598
|
+
: []
|
|
599
|
+
).map((marker) => basenameOf(marker)),
|
|
600
|
+
}),
|
|
601
|
+
),
|
|
602
|
+
remediation: (Array.isArray(scored?.remediations)
|
|
603
|
+
? scored.remediations
|
|
604
|
+
: []
|
|
605
|
+
).map(remediationJson),
|
|
606
|
+
observations: sortedBy(
|
|
607
|
+
reflection?.observations,
|
|
608
|
+
(observation) => observation?.kind,
|
|
609
|
+
(observation) => observation?.ecosystem,
|
|
610
|
+
(observation) => observation?.tool,
|
|
611
|
+
(observation) => observation?.remediationId,
|
|
612
|
+
).map((observation) => ({
|
|
613
|
+
kind: observation?.kind,
|
|
614
|
+
ecosystem: observation?.ecosystem,
|
|
615
|
+
tool: observation?.tool,
|
|
616
|
+
remediationId: observation?.remediationId,
|
|
617
|
+
impact: observation?.impact,
|
|
618
|
+
detail: redacted(observation?.detail),
|
|
619
|
+
command: redacted(observation?.command),
|
|
620
|
+
exitCode:
|
|
621
|
+
typeof observation?.exitCode === "number"
|
|
622
|
+
? observation.exitCode
|
|
623
|
+
: undefined,
|
|
624
|
+
causeDetail: redacted(observation?.causeDetail),
|
|
625
|
+
outputExcerpt: redacted(observation?.outputExcerpt),
|
|
626
|
+
})),
|
|
627
|
+
gate: gateSection(scored, options),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The verdict line: badge (tier and score) plus one sentence carrying the
|
|
633
|
+
* impression a reader must take away even if they stop reading there.
|
|
634
|
+
*
|
|
635
|
+
* At-ceiling rows are successes and never name a theme: a manifest-tier helm
|
|
636
|
+
* chart already holds the best fidelity its ecosystem permits, so accusing it
|
|
637
|
+
* of missing transitive dependencies would misinform the one-line reader.
|
|
638
|
+
* Unsupported-only runs put the coverage gap on cdxgen's side of the ledger.
|
|
639
|
+
*
|
|
640
|
+
* @param {Object} _reflection Reflection document (unused; the verdict reads the scoring).
|
|
641
|
+
* @param {Object} scored Scoring document.
|
|
642
|
+
* @returns {string} The verdict line.
|
|
643
|
+
*/
|
|
644
|
+
function verdictLine(_reflection, scored) {
|
|
645
|
+
const { tier } = overallAssessment(scored);
|
|
646
|
+
const rows = scoredRows(scored);
|
|
647
|
+
const score = scored?.overallScore ?? 100;
|
|
648
|
+
const remediations = Array.isArray(scored?.remediations)
|
|
649
|
+
? scored.remediations
|
|
650
|
+
: [];
|
|
651
|
+
const gaps = Array.isArray(scored?.unsupported) ? scored.unsupported : [];
|
|
652
|
+
const concernRows = rows.filter((row) => row?.state !== "at-ceiling");
|
|
653
|
+
const sentence = (() => {
|
|
654
|
+
if (remediations.length) {
|
|
655
|
+
const worstRank = concernRows.reduce(
|
|
656
|
+
(worst, row) =>
|
|
657
|
+
Math.max(worst, TIER_LADDER.indexOf(row?.tier ?? "absent")),
|
|
658
|
+
-1,
|
|
659
|
+
);
|
|
660
|
+
const names = concernRows
|
|
661
|
+
.filter(
|
|
662
|
+
(row) => TIER_LADDER.indexOf(row?.tier ?? "absent") === worstRank,
|
|
663
|
+
)
|
|
664
|
+
.map((row) => row?.ecosystem)
|
|
665
|
+
.filter(Boolean);
|
|
666
|
+
const theme =
|
|
667
|
+
TIER_THEMES[TIER_LADDER[worstRank]] ||
|
|
668
|
+
`the SBOM fidelity tier for {ecosystems} is ${TIER_LADDER[worstRank] || "unknown"}`;
|
|
669
|
+
return `${theme.replace("{ecosystems}", names.join(", "))}; ${remediations.length} remediation(s) proposed`;
|
|
670
|
+
}
|
|
671
|
+
const absentNames = concernRows
|
|
672
|
+
.filter((row) => row?.state === "absent")
|
|
673
|
+
.map((row) => row?.ecosystem)
|
|
674
|
+
.filter(Boolean);
|
|
675
|
+
if (absentNames.length) {
|
|
676
|
+
return TIER_THEMES.absent.replace("{ecosystems}", absentNames.join(", "));
|
|
677
|
+
}
|
|
678
|
+
if (concernRows.length) {
|
|
679
|
+
return "no remediations are pending; the SBOM reflects everything this environment can resolve";
|
|
680
|
+
}
|
|
681
|
+
const gapNames = gaps.map((gap) => gap?.ecosystem).filter(Boolean);
|
|
682
|
+
if (gapNames.length) {
|
|
683
|
+
return `cdxgen cannot parse ${gapNames.join(", ")} yet — a coverage gap in cdxgen, not a defect in your project`;
|
|
684
|
+
}
|
|
685
|
+
return "every scanned ecosystem is at its best achievable fidelity; nothing needs fixing";
|
|
686
|
+
})();
|
|
687
|
+
const tierLabel = tier ?? "ungraded";
|
|
688
|
+
return `Overall: ${tierLabel} (${score}/100) — ${sentence}.`;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Warnings about the report itself: the conditions under which its own
|
|
693
|
+
* verdicts deserve suspicion. Rendered above the findings, never at the
|
|
694
|
+
* bottom.
|
|
695
|
+
*
|
|
696
|
+
* @param {Object} reflection Reflection document.
|
|
697
|
+
* @param {Object} scored Scoring document.
|
|
698
|
+
* @returns {string[]} Warning sentences.
|
|
699
|
+
*/
|
|
700
|
+
function reportWarnings(reflection, scored) {
|
|
701
|
+
const warnings = [];
|
|
702
|
+
if (reflection?.ledgerSource === "none") {
|
|
703
|
+
warnings.push(
|
|
704
|
+
"No build ledger was available (this BOM was audited without a cdxgen run), so verdicts rest on BOM structure alone.",
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
if (
|
|
708
|
+
reflection?.formulation?.origin === "foreign" &&
|
|
709
|
+
Array.isArray(reflection?.formulation?.tools) &&
|
|
710
|
+
reflection.formulation.tools.length
|
|
711
|
+
) {
|
|
712
|
+
warnings.push(
|
|
713
|
+
"The BOM's formulation records the toolchain and CI commands of the machine that generated it, not this one; remediations derived from it describe that environment and its confidence is capped at medium.",
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
if (reflection?.ledgerComplete === false) {
|
|
717
|
+
warnings.push(
|
|
718
|
+
"The build ledger was truncated before the run finished, so findings may be incomplete; re-run with CDXGEN_INTROSPECT_LEDGER set to capture every event.",
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
const observations = Array.isArray(reflection?.observations)
|
|
722
|
+
? reflection.observations
|
|
723
|
+
: [];
|
|
724
|
+
if (
|
|
725
|
+
observations.some(
|
|
726
|
+
(observation) => observation?.kind === "ledger.memory-only",
|
|
727
|
+
)
|
|
728
|
+
) {
|
|
729
|
+
warnings.push(
|
|
730
|
+
"The ledger was read from this thread's memory, so worker-thread events are missing; set CDXGEN_INTROSPECT_LEDGER to capture them in a sidecar.",
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
const lowRows = scoredRows(scored).filter((row) => row?.confidence === "low");
|
|
734
|
+
if (
|
|
735
|
+
lowRows.length &&
|
|
736
|
+
reflection?.ledgerSource !== "none" &&
|
|
737
|
+
reflection?.ledgerComplete !== false
|
|
738
|
+
) {
|
|
739
|
+
const names = lowRows.map((row) => row?.ecosystem).join(", ");
|
|
740
|
+
warnings.push(
|
|
741
|
+
`The verdict for ${names} rests on marker detection alone (low confidence); a ledger sidecar would corroborate it.`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
return warnings;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* A rendered markdown table from header and row cells.
|
|
749
|
+
*
|
|
750
|
+
* @param {string[]} header Header cells.
|
|
751
|
+
* @param {string[][]} rows Body rows of cells.
|
|
752
|
+
* @returns {string[]} Table lines.
|
|
753
|
+
*/
|
|
754
|
+
function markdownTable(header, rows) {
|
|
755
|
+
return [
|
|
756
|
+
`| ${header.join(" | ")} |`,
|
|
757
|
+
`| ${header.map(() => "---").join(" | ")} |`,
|
|
758
|
+
...rows.map((cells) => `| ${cells.join(" | ")} |`),
|
|
759
|
+
];
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The per-ecosystem table: ecosystem, tier, score, components, edges,
|
|
764
|
+
* confidence. At-ceiling rows are successes and read as such.
|
|
765
|
+
*
|
|
766
|
+
* @param {Object[]} rows Scored rows.
|
|
767
|
+
* @returns {string[]} Table lines.
|
|
768
|
+
*/
|
|
769
|
+
function ecosystemTable(rows) {
|
|
770
|
+
return markdownTable(
|
|
771
|
+
["ecosystem", "tier", "score", "components", "edges", "confidence"],
|
|
772
|
+
rows.map((row) => [
|
|
773
|
+
mdCell(row?.ecosystem),
|
|
774
|
+
mdCell(row?.tier),
|
|
775
|
+
mdCell(row?.score),
|
|
776
|
+
mdCell(row?.componentCount),
|
|
777
|
+
mdCell(row?.dependencyEdgeCount),
|
|
778
|
+
mdCell(row?.confidence),
|
|
779
|
+
]),
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The tool-facts table for one ecosystem: one row per tool joining what was
|
|
785
|
+
* expected, resolved, missing and mismatched during the run.
|
|
786
|
+
*
|
|
787
|
+
* @param {Object} reflectionRow Reflection row.
|
|
788
|
+
* @returns {string[]} Table lines, empty when the run recorded no tool facts.
|
|
789
|
+
*/
|
|
790
|
+
function toolFactsTable(reflectionRow) {
|
|
791
|
+
const byTool = new Map();
|
|
792
|
+
const rowFor = (tool) => {
|
|
793
|
+
let row = byTool.get(tool);
|
|
794
|
+
if (!row) {
|
|
795
|
+
row = { expected: [], resolved: [], missing: [], mismatched: [] };
|
|
796
|
+
byTool.set(tool, row);
|
|
797
|
+
}
|
|
798
|
+
return row;
|
|
799
|
+
};
|
|
800
|
+
for (const entry of reflectionRow?.toolsExpected || []) {
|
|
801
|
+
rowFor(entry?.tool).expected.push(`${entry?.wanted} (${entry?.source})`);
|
|
802
|
+
}
|
|
803
|
+
for (const entry of reflectionRow?.toolsResolved || []) {
|
|
804
|
+
rowFor(entry?.tool).resolved.push(
|
|
805
|
+
`${entry?.found || "present"} (${entry?.source})`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
for (const entry of reflectionRow?.toolsMissing || []) {
|
|
809
|
+
rowFor(entry?.tool).missing.push(`yes (${entry?.source})`);
|
|
810
|
+
}
|
|
811
|
+
for (const entry of reflectionRow?.toolsMismatched || []) {
|
|
812
|
+
rowFor(entry?.tool).mismatched.push(
|
|
813
|
+
`wanted ${entry?.wanted}, found ${entry?.found}`,
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
if (!byTool.size) {
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
return markdownTable(
|
|
820
|
+
["tool", "expected", "resolved", "missing", "mismatched"],
|
|
821
|
+
sortedBy([...byTool.entries()], (entry) => entry[0]).map(
|
|
822
|
+
([tool, facts]) => [
|
|
823
|
+
mdCell(tool),
|
|
824
|
+
mdCell(facts.expected.sort().join("; ")),
|
|
825
|
+
mdCell(facts.resolved.sort().join("; ")),
|
|
826
|
+
mdCell(facts.missing.sort().join("; ")),
|
|
827
|
+
mdCell(facts.mismatched.sort().join("; ")),
|
|
828
|
+
],
|
|
829
|
+
),
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The verification sentence of a remediation, composed from whatever checks
|
|
835
|
+
* its verify clause carries.
|
|
836
|
+
*
|
|
837
|
+
* @param {Object} verify Verify clause.
|
|
838
|
+
* @returns {string} The sentence, or an empty string when nothing is checkable.
|
|
839
|
+
*/
|
|
840
|
+
function verifySentence(verify) {
|
|
841
|
+
const parts = [];
|
|
842
|
+
const rules = Array.isArray(verify?.rules) ? verify.rules : [];
|
|
843
|
+
if (rules.length) {
|
|
844
|
+
parts.push(
|
|
845
|
+
`${rules.map((rule) => `\`${rule}\``).join(", ")} no longer fire${rules.length === 1 ? "s" : ""}`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (verify?.expectTier) {
|
|
849
|
+
parts.push(`the tier reaches \`${verify.expectTier}\``);
|
|
850
|
+
}
|
|
851
|
+
const events = Array.isArray(verify?.eventsCleared)
|
|
852
|
+
? verify.eventsCleared
|
|
853
|
+
: [];
|
|
854
|
+
if (events.length) {
|
|
855
|
+
parts.push(
|
|
856
|
+
`no further \`${events.join("`, `")}\` event${events.length === 1 ? " is" : "s are"} recorded`,
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
if (!parts.length) {
|
|
860
|
+
return "";
|
|
861
|
+
}
|
|
862
|
+
return `Confirm ${parts.join(" and ")}.`;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* The command blocks and notes of one remediation. POSIX and Windows variants
|
|
867
|
+
* live in separate fenced blocks; prose-only actions and the re-run reminder
|
|
868
|
+
* become notes, because pasting prose into a shell helps nobody.
|
|
869
|
+
*
|
|
870
|
+
* @param {Object} entry Remediation entry.
|
|
871
|
+
* @param {Object} reflection Reflection document.
|
|
872
|
+
* @param {Object} options CLI options.
|
|
873
|
+
* @returns {string[]} Block and note lines.
|
|
874
|
+
*/
|
|
875
|
+
function commandBlocks(entry, reflection, options) {
|
|
876
|
+
const lines = [];
|
|
877
|
+
const actions = Array.isArray(entry?.actions) ? entry.actions : [];
|
|
878
|
+
const posix = [];
|
|
879
|
+
const windows = [];
|
|
880
|
+
const windowsComments = [];
|
|
881
|
+
const notes = [];
|
|
882
|
+
let hasCommands = false;
|
|
883
|
+
for (const action of actions) {
|
|
884
|
+
// The provenance of a substituted version — and the explicit absence of
|
|
885
|
+
// one — belongs next to the command blocks, or an unsubstituted
|
|
886
|
+
// `{{version}}` reads like a template bug rather than a question to ask.
|
|
887
|
+
if (action?.versionSourceMissing) {
|
|
888
|
+
notes.push(
|
|
889
|
+
`no version for \`${action?.tool}\` was recorded by this run, so \`{{version}}\` is left unsubstituted — ask the user which version to install; never invent one`,
|
|
890
|
+
);
|
|
891
|
+
} else if (action?.versionSource) {
|
|
892
|
+
notes.push(
|
|
893
|
+
`${action?.tool} version ${mdProse(action.versionSource)} (${action.versionFrom})`,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
if (action?.shapedBy) {
|
|
897
|
+
notes.push(
|
|
898
|
+
`the command was shaped for this project (${mdProse(action.shapedBy)})`,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
if (!COMMAND_KINDS.has(action?.kind) || !action?.command) {
|
|
902
|
+
if (action?.kind === "rerun") {
|
|
903
|
+
notes.push(
|
|
904
|
+
"Re-run the cdxgen invocation from the Reproduce section to confirm the fix.",
|
|
905
|
+
);
|
|
906
|
+
} else if (action?.command) {
|
|
907
|
+
notes.push(`${action.kind}: ${mdProse(action.command)}`);
|
|
908
|
+
}
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
hasCommands = true;
|
|
912
|
+
posix.push(codeLine(action.command));
|
|
913
|
+
if (typeof action.windows === "string") {
|
|
914
|
+
windows.push(codeLine(action.windows));
|
|
915
|
+
} else if (action.windows === null) {
|
|
916
|
+
windowsComments.push(
|
|
917
|
+
`# no Windows equivalent: ${mdProse(action.windowsReason || "unspecified")}`,
|
|
918
|
+
);
|
|
919
|
+
windows.push(null);
|
|
920
|
+
} else {
|
|
921
|
+
windows.push(codeLine(action.command));
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (!hasCommands && entry?.source === "rule") {
|
|
925
|
+
// A rule-derived remediation has no catalog actions; the report proposes
|
|
926
|
+
// the re-scan in which cdxgen itself drives the ecosystem's resolver, the
|
|
927
|
+
// same fix the rule's mitigation names, so the reader always has a
|
|
928
|
+
// pasteable first step.
|
|
929
|
+
const invocation = cdxgenInvocation(reflection, {
|
|
930
|
+
...options,
|
|
931
|
+
installDeps: true,
|
|
932
|
+
});
|
|
933
|
+
posix.push(codeLine(invocation));
|
|
934
|
+
windows.push(codeLine(invocation));
|
|
935
|
+
hasCommands = true;
|
|
936
|
+
}
|
|
937
|
+
if (hasCommands) {
|
|
938
|
+
lines.push("POSIX:", "", "```sh", ...posix, "```", "");
|
|
939
|
+
const windowsLines = windows.map((line) =>
|
|
940
|
+
line === null ? windowsComments.shift() : line,
|
|
941
|
+
);
|
|
942
|
+
lines.push("Windows:", "", "```bat", ...windowsLines, "```", "");
|
|
943
|
+
}
|
|
944
|
+
if (notes.length) {
|
|
945
|
+
for (const note of notes) {
|
|
946
|
+
lines.push(`- ${note}`);
|
|
947
|
+
}
|
|
948
|
+
lines.push("");
|
|
949
|
+
}
|
|
950
|
+
return lines;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* One numbered remediation section.
|
|
955
|
+
*
|
|
956
|
+
* @param {number} number Section number in ranked order.
|
|
957
|
+
* @param {Object} entry Remediation entry.
|
|
958
|
+
* @param {Object} reflection Reflection document.
|
|
959
|
+
* @param {Object} options CLI options.
|
|
960
|
+
* @returns {string[]} Section lines.
|
|
961
|
+
*/
|
|
962
|
+
function remediationSection(number, entry, reflection, options) {
|
|
963
|
+
const lines = [];
|
|
964
|
+
const why = entry?.guidance
|
|
965
|
+
? mdProse(entry.guidance)
|
|
966
|
+
: `${
|
|
967
|
+
IMPACT_WHY[entry?.impact] ||
|
|
968
|
+
"recorded events degraded the build evidence"
|
|
969
|
+
} (${entry?.evidenceCount || 1} event${(entry?.evidenceCount || 1) === 1 ? "" : "s"}).`;
|
|
970
|
+
lines.push(
|
|
971
|
+
`### ${number}. ${mdProse(entry?.summary || entry?.remediationId)}`,
|
|
972
|
+
"",
|
|
973
|
+
);
|
|
974
|
+
lines.push(
|
|
975
|
+
`- Remediation: \`${entry?.remediationId}\` (source: ${entry?.source}) — ecosystem: \`${entry?.ecosystem}\`, confidence: ${entry?.confidence}`,
|
|
976
|
+
);
|
|
977
|
+
lines.push(`- Why: ${why}`);
|
|
978
|
+
lines.push(...evidenceLines(entry));
|
|
979
|
+
lines.push(
|
|
980
|
+
`- Score: ${entry?.currentScore} → ${entry?.projectedScore} (tier \`${entry?.targetTier}\`); expected overall gain: +${Number(entry?.expectedGain ?? 0).toFixed(2)}`,
|
|
981
|
+
);
|
|
982
|
+
const subsumes = Array.isArray(entry?.subsumes) ? entry.subsumes : [];
|
|
983
|
+
if (subsumes.length) {
|
|
984
|
+
lines.push(
|
|
985
|
+
`- Also resolves: ${subsumes.map((rule) => `\`${rule}\``).join(", ")}`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
const verify = verifySentence(entry?.verify);
|
|
989
|
+
if (verify) {
|
|
990
|
+
lines.push(`- ${verify}`);
|
|
991
|
+
}
|
|
992
|
+
if (entry?.docs) {
|
|
993
|
+
lines.push(`- Docs: ${entry.docs}`);
|
|
994
|
+
}
|
|
995
|
+
lines.push("");
|
|
996
|
+
lines.push(...commandBlocks(entry, reflection, options));
|
|
997
|
+
lines.push(...excerptLines(entry));
|
|
998
|
+
return lines;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Render the markdown report.
|
|
1003
|
+
*
|
|
1004
|
+
* @param {Object} reflection Reflection document from reflectOnRun.
|
|
1005
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
1006
|
+
* @param {Object} [options] CLI options that name the run's outputs.
|
|
1007
|
+
* @param {string} [options.output] Path the BOM was written to.
|
|
1008
|
+
* @param {boolean} [options.installDeps] Whether the run installs dependencies.
|
|
1009
|
+
* @returns {string} The complete markdown report.
|
|
1010
|
+
*/
|
|
1011
|
+
export function renderIntrospectionMarkdown(reflection, scored, options = {}) {
|
|
1012
|
+
const lines = [];
|
|
1013
|
+
const rows = scoredRows(scored);
|
|
1014
|
+
const remediations = Array.isArray(scored?.remediations)
|
|
1015
|
+
? scored.remediations
|
|
1016
|
+
: [];
|
|
1017
|
+
const actionable = remediations.filter((entry) => entry?.blocked !== true);
|
|
1018
|
+
const blocked = remediations.filter((entry) => entry?.blocked === true);
|
|
1019
|
+
const gaps = sortedBy(scored?.unsupported, (gap) => gap?.ecosystem);
|
|
1020
|
+
const reflectionRows = new Map(
|
|
1021
|
+
(Array.isArray(reflection?.ecosystems) ? reflection.ecosystems : []).map(
|
|
1022
|
+
(row) => [row?.ecosystem, row],
|
|
1023
|
+
),
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
lines.push("# cdxgen build introspection", "");
|
|
1027
|
+
lines.push(verdictLine(reflection, scored), "");
|
|
1028
|
+
|
|
1029
|
+
const warnings = reportWarnings(reflection, scored);
|
|
1030
|
+
if (warnings.length) {
|
|
1031
|
+
lines.push("## Warnings about this report", "");
|
|
1032
|
+
for (const warning of warnings) {
|
|
1033
|
+
lines.push(`- ${warning}`);
|
|
1034
|
+
}
|
|
1035
|
+
lines.push("");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (rows.length) {
|
|
1039
|
+
lines.push("## Ecosystems", "");
|
|
1040
|
+
lines.push(...ecosystemTable(rows), "");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (gaps.length) {
|
|
1044
|
+
lines.push("## cdxgen coverage gaps", "");
|
|
1045
|
+
lines.push(
|
|
1046
|
+
"The following ecosystems have markers on disk but no cdxgen project type yet, so nothing was parsed. This is a gap in cdxgen's ecosystem coverage, not a problem with your project; it belongs on cdxgen's backlog.",
|
|
1047
|
+
"",
|
|
1048
|
+
);
|
|
1049
|
+
for (const gap of gaps) {
|
|
1050
|
+
const markers = (
|
|
1051
|
+
Array.isArray(gap?.markersOnDisk) ? gap.markersOnDisk : []
|
|
1052
|
+
)
|
|
1053
|
+
.map((marker) => `\`${mdCell(basenameOf(marker))}\``)
|
|
1054
|
+
.join(", ");
|
|
1055
|
+
lines.push(
|
|
1056
|
+
`- \`${mdCell(gap?.ecosystem)}\`${markers ? ` — markers: ${markers}` : ""}`,
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
lines.push("");
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (actionable.length) {
|
|
1063
|
+
lines.push("## What to fix", "");
|
|
1064
|
+
lines.push("Ranked by expected score gain.", "");
|
|
1065
|
+
for (const [index, entry] of actionable.entries()) {
|
|
1066
|
+
lines.push(...remediationSection(index + 1, entry, reflection, options));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (blocked.length) {
|
|
1071
|
+
lines.push("## Cannot be fixed from this environment", "");
|
|
1072
|
+
lines.push(
|
|
1073
|
+
"These remediations would help, but a constraint of this run blocks them. Lift the constraint and they become actionable.",
|
|
1074
|
+
"",
|
|
1075
|
+
);
|
|
1076
|
+
for (const [index, entry] of blocked.entries()) {
|
|
1077
|
+
lines.push(
|
|
1078
|
+
...remediationSection(
|
|
1079
|
+
actionable.length + index + 1,
|
|
1080
|
+
entry,
|
|
1081
|
+
reflection,
|
|
1082
|
+
options,
|
|
1083
|
+
),
|
|
1084
|
+
);
|
|
1085
|
+
lines.push(
|
|
1086
|
+
`- Blocked: ${mdProse(entry?.blockedReason || "reason not recorded")}`,
|
|
1087
|
+
"",
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const evidenceRows = rows.filter((row) => reflectionRows.get(row?.ecosystem));
|
|
1093
|
+
if (evidenceRows.length || (reflection?.globalFindings || []).length) {
|
|
1094
|
+
lines.push("## Evidence", "");
|
|
1095
|
+
for (const row of evidenceRows) {
|
|
1096
|
+
const reflectionRow = reflectionRows.get(row?.ecosystem);
|
|
1097
|
+
lines.push(`### ${mdCell(row?.ecosystem)}`, "");
|
|
1098
|
+
const toolLines = toolFactsTable(reflectionRow);
|
|
1099
|
+
if (toolLines.length) {
|
|
1100
|
+
lines.push("Tools:", "", ...toolLines, "");
|
|
1101
|
+
}
|
|
1102
|
+
const findings = sortedBy(
|
|
1103
|
+
reflectionRow?.findings,
|
|
1104
|
+
(finding) => finding?.ruleId,
|
|
1105
|
+
(finding) => finding?.message,
|
|
1106
|
+
);
|
|
1107
|
+
if (findings.length) {
|
|
1108
|
+
if (row?.state === "at-ceiling") {
|
|
1109
|
+
lines.push(
|
|
1110
|
+
"Rule findings (informational — this ecosystem already parses at its best achievable fidelity, so no action is proposed):",
|
|
1111
|
+
"",
|
|
1112
|
+
);
|
|
1113
|
+
} else {
|
|
1114
|
+
lines.push("Rule findings:", "");
|
|
1115
|
+
}
|
|
1116
|
+
for (const finding of findings) {
|
|
1117
|
+
lines.push(
|
|
1118
|
+
`- **${mdCell(finding?.ruleId)}** (${mdCell(finding?.severity)}): ${mdProse(finding?.message)}`,
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
lines.push("");
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const globalFindings = sortedBy(
|
|
1125
|
+
reflection?.globalFindings,
|
|
1126
|
+
(finding) => finding?.ruleId,
|
|
1127
|
+
(finding) => finding?.message,
|
|
1128
|
+
);
|
|
1129
|
+
if (globalFindings.length) {
|
|
1130
|
+
lines.push("### global", "");
|
|
1131
|
+
lines.push("Findings that could not be attributed to one ecosystem:", "");
|
|
1132
|
+
for (const finding of globalFindings) {
|
|
1133
|
+
lines.push(
|
|
1134
|
+
`- **${mdCell(finding?.ruleId)}** (${mdCell(finding?.severity)}): ${mdProse(finding?.message)}`,
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
lines.push("");
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
lines.push("## Reproduce", "");
|
|
1142
|
+
lines.push(
|
|
1143
|
+
"```sh",
|
|
1144
|
+
codeLine(cdxgenInvocation(reflection, options)),
|
|
1145
|
+
"```",
|
|
1146
|
+
"",
|
|
1147
|
+
);
|
|
1148
|
+
const facts = [];
|
|
1149
|
+
if (reflection?.cdxgenVersion) {
|
|
1150
|
+
facts.push(
|
|
1151
|
+
`cdxgen version: \`${reflection.cdxgenVersion}\`; runtime: ${reflection?.runtime?.name || "unknown"} ${reflection?.runtime?.version || ""}`.trimEnd(),
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
if (reflection?.inputsFingerprint) {
|
|
1155
|
+
facts.push(
|
|
1156
|
+
`Inputs fingerprint: \`sha256:${reflection.inputsFingerprint}\``,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
for (const fact of facts) {
|
|
1160
|
+
lines.push(`- ${fact}`);
|
|
1161
|
+
}
|
|
1162
|
+
lines.push("");
|
|
1163
|
+
return lines.join("\n");
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Render the console summary: a handful of diagnostic lines carrying the
|
|
1168
|
+
* verdict, the remediation counts, and the paths the reports were written
|
|
1169
|
+
* to. The full report never goes to the console.
|
|
1170
|
+
*
|
|
1171
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
1172
|
+
* @param {Object} [options] CLI options naming the report outputs.
|
|
1173
|
+
* @param {string} [options.introspectReport] Path of the markdown report.
|
|
1174
|
+
* @param {string} [options.introspectJson] Path of the JSON report.
|
|
1175
|
+
* @returns {string} Newline-terminated lines for the diagnostic stream.
|
|
1176
|
+
*/
|
|
1177
|
+
export function renderIntrospectionConsole(scored, options = {}) {
|
|
1178
|
+
const { tier, confidence } = overallAssessment(scored);
|
|
1179
|
+
const remediations = Array.isArray(scored?.remediations)
|
|
1180
|
+
? scored.remediations
|
|
1181
|
+
: [];
|
|
1182
|
+
const blocked = remediations.filter(
|
|
1183
|
+
(entry) => entry?.blocked === true,
|
|
1184
|
+
).length;
|
|
1185
|
+
const gaps = Array.isArray(scored?.unsupported)
|
|
1186
|
+
? scored.unsupported.length
|
|
1187
|
+
: 0;
|
|
1188
|
+
const lines = [
|
|
1189
|
+
`Build introspection: overall ${tier ?? "ungraded"} (${scored?.overallScore ?? 100}/100), confidence ${confidence ?? "n/a"}`,
|
|
1190
|
+
];
|
|
1191
|
+
const counts = [`${remediations.length} remediation(s) ranked`];
|
|
1192
|
+
if (blocked) {
|
|
1193
|
+
counts.push(`${blocked} blocked`);
|
|
1194
|
+
}
|
|
1195
|
+
if (gaps) {
|
|
1196
|
+
counts.push(`${gaps} coverage gap(s)`);
|
|
1197
|
+
}
|
|
1198
|
+
lines.push(`Build introspection: ${counts.join(", ")}`);
|
|
1199
|
+
if (options?.introspectReport) {
|
|
1200
|
+
lines.push(
|
|
1201
|
+
`Build introspection: markdown report: ${options.introspectReport}`,
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
if (options?.introspectJson) {
|
|
1205
|
+
lines.push(`Build introspection: json report: ${options.introspectJson}`);
|
|
1206
|
+
}
|
|
1207
|
+
return `${lines.join("\n")}\n`;
|
|
1208
|
+
}
|