@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,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOM mutations that carry the introspection verdict inside the document:
|
|
3
|
+
* eight metadata properties and a block of document-level annotations.
|
|
4
|
+
*
|
|
5
|
+
* Sidecar report files get lost; the BOM travels. A consumer who receives
|
|
6
|
+
* only the BOM still learns how much to trust it from the metadata properties,
|
|
7
|
+
* and the annotations carry one entry per remediation the loop should act on.
|
|
8
|
+
* No component or dependency is touched.
|
|
9
|
+
*
|
|
10
|
+
* Both mutations are replace-by-name: a document that was already introspected
|
|
11
|
+
* (a BOM enriched again after an evidence pass) loses its previous
|
|
12
|
+
* introspection state and gains a fresh one, so the verdict always describes
|
|
13
|
+
* the final document.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
buildDocumentAnnotation,
|
|
18
|
+
evidenceProperties,
|
|
19
|
+
} from "../annotationBuilder.js";
|
|
20
|
+
import { FORMULATION_RUN_ID_PROPERTY } from "./formulationEvidence.js";
|
|
21
|
+
import { overallAssessment } from "./report.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Version of the introspection BOM surface. Matches the JSON report's
|
|
25
|
+
* schemaVersion so the two stay comparable.
|
|
26
|
+
*
|
|
27
|
+
* @type {string}
|
|
28
|
+
*/
|
|
29
|
+
export const INTROSPECTION_SCHEMA_VERSION = "1.1";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Namespace of every property and annotation entry this module emits.
|
|
33
|
+
*
|
|
34
|
+
* @type {string}
|
|
35
|
+
*/
|
|
36
|
+
export const INTROSPECTION_PROPERTY_PREFIX = "cdx:introspection";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The metadata properties that summarize the verdict, in emission order. The
|
|
40
|
+
* overall tier and confidence are omitted when the run produced no scored row
|
|
41
|
+
* (nothing was graded, so no verdict is claimed); per-ecosystem rows cover the
|
|
42
|
+
* scored ecosystems only, since unsupported ones have neither a tier nor a
|
|
43
|
+
* score.
|
|
44
|
+
*
|
|
45
|
+
* @param {Object} reflection Reflection document from reflectOnRun.
|
|
46
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
47
|
+
* @returns {{name: string, value: string}[]} Metadata property entries.
|
|
48
|
+
*/
|
|
49
|
+
export function introspectionMetadataProperties(reflection, scored) {
|
|
50
|
+
const assessment = overallAssessment(scored);
|
|
51
|
+
const properties = [
|
|
52
|
+
{
|
|
53
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:schemaVersion`,
|
|
54
|
+
value: INTROSPECTION_SCHEMA_VERSION,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
if (assessment.tier) {
|
|
58
|
+
properties.push({
|
|
59
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:tier`,
|
|
60
|
+
value: assessment.tier,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
properties.push({
|
|
64
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:score`,
|
|
65
|
+
value: String(scored?.overallScore ?? 100),
|
|
66
|
+
});
|
|
67
|
+
if (assessment.confidence) {
|
|
68
|
+
properties.push({
|
|
69
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:confidence`,
|
|
70
|
+
value: assessment.confidence,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
properties.push({
|
|
74
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:ledgerComplete`,
|
|
75
|
+
value: String(reflection?.ledgerComplete !== false),
|
|
76
|
+
});
|
|
77
|
+
for (const row of sortedScoredRows(scored)) {
|
|
78
|
+
properties.push({
|
|
79
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:ecosystem:${row.ecosystem}:tier`,
|
|
80
|
+
value: `${row.tier}`,
|
|
81
|
+
});
|
|
82
|
+
properties.push({
|
|
83
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:ecosystem:${row.ecosystem}:score`,
|
|
84
|
+
value: String(row.score),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
properties.push({
|
|
88
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:remediationCount`,
|
|
89
|
+
value: String(actionableRemediations(scored).length),
|
|
90
|
+
});
|
|
91
|
+
return properties;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The annotation describing the run as a whole. Its structured facts are the
|
|
96
|
+
* metadata properties, so the summary is readable without the rest of the
|
|
97
|
+
* document.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} bomJson CycloneDX BOM the annotation describes.
|
|
100
|
+
* @param {Object} reflection Reflection document from reflectOnRun.
|
|
101
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
102
|
+
* @returns {Object|undefined} The summary annotation, or undefined without a cdxgen annotator.
|
|
103
|
+
*/
|
|
104
|
+
function summaryAnnotation(bomJson, reflection, scored) {
|
|
105
|
+
const assessment = overallAssessment(scored);
|
|
106
|
+
const remediationCount = (
|
|
107
|
+
Array.isArray(scored?.remediations) ? scored.remediations : []
|
|
108
|
+
).length;
|
|
109
|
+
// Annotation text is escaped as markdown before it reaches the BOM, so the
|
|
110
|
+
// summary is phrased without brackets or pipes that would survive as
|
|
111
|
+
// backslashes in the rendered document.
|
|
112
|
+
const message =
|
|
113
|
+
`Build introspection: overall tier ${assessment.tier ?? "ungraded"}, ` +
|
|
114
|
+
`score ${scored?.overallScore ?? 100} of 100, ` +
|
|
115
|
+
`confidence ${assessment.confidence ?? "not available"}. ` +
|
|
116
|
+
`${remediationCount} remediation${remediationCount === 1 ? "" : "s"} ranked.`;
|
|
117
|
+
return buildDocumentAnnotation({
|
|
118
|
+
bomJson,
|
|
119
|
+
message,
|
|
120
|
+
properties: introspectionMetadataProperties(reflection, scored),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One annotation per non-blocked remediation, in ranked order. Blocked
|
|
126
|
+
* entries are constraints of the environment rather than work the loop can
|
|
127
|
+
* do, so they stay in the report files and out of the document. A remediation
|
|
128
|
+
* that subsumes other rules names them, so nothing the run detected goes
|
|
129
|
+
* unreported.
|
|
130
|
+
*
|
|
131
|
+
* @param {Object} bomJson CycloneDX BOM the annotations describe.
|
|
132
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
133
|
+
* @returns {Object[]} Remediation annotations.
|
|
134
|
+
*/
|
|
135
|
+
function remediationAnnotations(bomJson, scored) {
|
|
136
|
+
const tierByEcosystem = new Map(
|
|
137
|
+
sortedScoredRows(scored).map((row) => [row.ecosystem, row.tier]),
|
|
138
|
+
);
|
|
139
|
+
const annotations = [];
|
|
140
|
+
for (const entry of actionableRemediations(scored)) {
|
|
141
|
+
const properties = [
|
|
142
|
+
{
|
|
143
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:remediationId`,
|
|
144
|
+
value: `${entry.remediationId}`,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:ecosystem`,
|
|
148
|
+
value: `${entry.ecosystem}`,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:currentTier`,
|
|
152
|
+
value: `${tierByEcosystem.get(entry.ecosystem) || entry.targetTier}`,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:targetTier`,
|
|
156
|
+
value: `${entry.targetTier}`,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:expectedGain`,
|
|
160
|
+
value: Number(entry.expectedGain ?? 0).toFixed(2),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:confidence`,
|
|
164
|
+
value: `${entry.confidence}`,
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
const rules = Array.isArray(entry.verify?.rules) ? entry.verify.rules : [];
|
|
168
|
+
if (rules.length) {
|
|
169
|
+
properties.push({
|
|
170
|
+
name: `${INTROSPECTION_PROPERTY_PREFIX}:verify:rules`,
|
|
171
|
+
value: rules.join(","),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const evidence = { source: entry.source };
|
|
175
|
+
if (entry.impact) {
|
|
176
|
+
evidence.impact = entry.impact;
|
|
177
|
+
}
|
|
178
|
+
if (entry.severity) {
|
|
179
|
+
evidence.severity = entry.severity;
|
|
180
|
+
}
|
|
181
|
+
if (Number.isFinite(entry.evidenceCount)) {
|
|
182
|
+
evidence.events = entry.evidenceCount;
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(entry.subsumes) && entry.subsumes.length) {
|
|
185
|
+
evidence.subsumes = entry.subsumes;
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
Array.isArray(entry.verify?.eventsCleared) &&
|
|
189
|
+
entry.verify.eventsCleared.length
|
|
190
|
+
) {
|
|
191
|
+
evidence.eventsCleared = entry.verify.eventsCleared;
|
|
192
|
+
}
|
|
193
|
+
properties.push(
|
|
194
|
+
...evidenceProperties(INTROSPECTION_PROPERTY_PREFIX, evidence),
|
|
195
|
+
);
|
|
196
|
+
const annotation = buildDocumentAnnotation({
|
|
197
|
+
bomJson,
|
|
198
|
+
message: `${entry.summary || entry.remediationId}`,
|
|
199
|
+
properties,
|
|
200
|
+
});
|
|
201
|
+
if (annotation) {
|
|
202
|
+
annotations.push(annotation);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return annotations;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Attach the introspection verdict to the document: metadata properties plus
|
|
210
|
+
* the summary and remediation annotations. Any introspection state left on the
|
|
211
|
+
* document by a previous pass is removed first, so enrichment flows that run
|
|
212
|
+
* post-processing twice leave exactly one verdict.
|
|
213
|
+
*
|
|
214
|
+
* @param {Object} bomJson CycloneDX BOM, mutated in place.
|
|
215
|
+
* @param {Object} reflection Reflection document from reflectOnRun.
|
|
216
|
+
* @param {Object} scored Scoring document from scoreReflection.
|
|
217
|
+
* @param {Object} [guards] Emission guards.
|
|
218
|
+
* @param {boolean} [guards.annotations] False skips the annotations, for documents at a spec version that predates them.
|
|
219
|
+
* @returns {Object} The mutated BOM.
|
|
220
|
+
*/
|
|
221
|
+
export function applyIntrospectionToBom(
|
|
222
|
+
bomJson,
|
|
223
|
+
reflection,
|
|
224
|
+
scored,
|
|
225
|
+
guards = {},
|
|
226
|
+
) {
|
|
227
|
+
if (!bomJson?.metadata) {
|
|
228
|
+
return bomJson;
|
|
229
|
+
}
|
|
230
|
+
const freshProperties = introspectionMetadataProperties(reflection, scored);
|
|
231
|
+
const retainedProperties = (
|
|
232
|
+
Array.isArray(bomJson.metadata.properties)
|
|
233
|
+
? bomJson.metadata.properties
|
|
234
|
+
: []
|
|
235
|
+
).filter(
|
|
236
|
+
(property) =>
|
|
237
|
+
!`${property?.name || ""}`.startsWith(INTROSPECTION_PROPERTY_PREFIX),
|
|
238
|
+
);
|
|
239
|
+
bomJson.metadata.properties = [...retainedProperties, ...freshProperties];
|
|
240
|
+
if (guards.annotations === false) {
|
|
241
|
+
return bomJson;
|
|
242
|
+
}
|
|
243
|
+
const retainedAnnotations = (
|
|
244
|
+
Array.isArray(bomJson.annotations) ? bomJson.annotations : []
|
|
245
|
+
).filter(
|
|
246
|
+
(annotation) =>
|
|
247
|
+
!`${annotation?.text || ""}`.includes(INTROSPECTION_PROPERTY_PREFIX),
|
|
248
|
+
);
|
|
249
|
+
const freshAnnotations = [
|
|
250
|
+
summaryAnnotation(bomJson, reflection, scored),
|
|
251
|
+
...remediationAnnotations(bomJson, scored),
|
|
252
|
+
].filter(Boolean);
|
|
253
|
+
if (freshAnnotations.length) {
|
|
254
|
+
bomJson.annotations = [...retainedAnnotations, ...freshAnnotations];
|
|
255
|
+
}
|
|
256
|
+
return bomJson;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Scored rows in ecosystem order. */
|
|
260
|
+
function sortedScoredRows(scored) {
|
|
261
|
+
return [...(Array.isArray(scored?.ecosystems) ? scored.ecosystems : [])]
|
|
262
|
+
.filter((row) => row?.ecosystem)
|
|
263
|
+
.sort((a, b) => `${a.ecosystem}`.localeCompare(`${b.ecosystem}`));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Remediations the loop can act on, in ranked order. */
|
|
267
|
+
function actionableRemediations(scored) {
|
|
268
|
+
return (
|
|
269
|
+
Array.isArray(scored?.remediations) ? scored.remediations : []
|
|
270
|
+
).filter((entry) => entry?.blocked !== true);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Stamp every formulation workflow with the id of the run that reflected over
|
|
275
|
+
* the document, so a later consumer of the BOM can tell a formulation record
|
|
276
|
+
* this run generated from one that arrived with a foreign BOM. The stamp is
|
|
277
|
+
* replace-by-name, matching how the rest of the verdict mutates the
|
|
278
|
+
* document: a BOM enriched twice keeps only the latest run's id.
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} bomJson CycloneDX BOM, mutated in place.
|
|
281
|
+
* @param {string} runId Run id of the introspection pass.
|
|
282
|
+
* @returns {Object} The mutated BOM.
|
|
283
|
+
*/
|
|
284
|
+
export function applyFormulationRunIdMarker(bomJson, runId) {
|
|
285
|
+
if (!bomJson || !runId) {
|
|
286
|
+
return bomJson;
|
|
287
|
+
}
|
|
288
|
+
for (const entry of Array.isArray(bomJson.formulation)
|
|
289
|
+
? bomJson.formulation
|
|
290
|
+
: []) {
|
|
291
|
+
for (const workflow of Array.isArray(entry?.workflows)
|
|
292
|
+
? entry.workflows
|
|
293
|
+
: []) {
|
|
294
|
+
const retained = (
|
|
295
|
+
Array.isArray(workflow.properties) ? workflow.properties : []
|
|
296
|
+
).filter((property) => property?.name !== FORMULATION_RUN_ID_PROPERTY);
|
|
297
|
+
workflow.properties = [
|
|
298
|
+
...retained,
|
|
299
|
+
{ name: FORMULATION_RUN_ID_PROPERTY, value: runId },
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return bomJson;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Whether a BOM already carries an introspection verdict.
|
|
308
|
+
*
|
|
309
|
+
* Evidence collection re-processes a finished BOM through a fresh wrapper, so
|
|
310
|
+
* the document itself is the only marker that survives between passes.
|
|
311
|
+
*
|
|
312
|
+
* @param {Object} bomJson CycloneDX BOM.
|
|
313
|
+
* @returns {boolean} True when the BOM was already introspected.
|
|
314
|
+
*/
|
|
315
|
+
export function hasIntrospectionVerdict(bomJson) {
|
|
316
|
+
return (bomJson?.metadata?.properties || []).some(
|
|
317
|
+
(property) =>
|
|
318
|
+
property?.name === `${INTROSPECTION_PROPERTY_PREFIX}:schemaVersion`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-facts collector for command shaping.
|
|
3
|
+
*
|
|
4
|
+
* Gathers, once per run and cached, the project facts the command resolver
|
|
5
|
+
* consumes: which maven and gradle wrapper scripts the project root carries,
|
|
6
|
+
* which Python manager owns its lock files, and which npm client the project
|
|
7
|
+
* names or locks. Detection is filesystem reads only — existence and, for
|
|
8
|
+
* the platform's own shell scripts, the executable bit. Nothing here spawns
|
|
9
|
+
* a process: whether a wrapper actually runs is a question the run itself
|
|
10
|
+
* answers elsewhere, and shaping that cannot know says so through the
|
|
11
|
+
* resolver's fallbacks.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
safeExistsSync,
|
|
18
|
+
safeIsExecutableSync,
|
|
19
|
+
safeReadFileSync,
|
|
20
|
+
} from "../../../core/fs.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The POSIX wrapper scripts whose executable bit is meaningful, mapped to
|
|
24
|
+
* the fact names the resolver reads.
|
|
25
|
+
*
|
|
26
|
+
* @type {ReadonlyArray<[string, string]>}
|
|
27
|
+
*/
|
|
28
|
+
const POSIX_WRAPPERS = [
|
|
29
|
+
["mvnw", "mvnw"],
|
|
30
|
+
["gradlew", "gradlew"],
|
|
31
|
+
["composer.phar", "composerPhar"],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The Windows wrapper batches whose presence is the practical usability
|
|
36
|
+
* test, mapped to the fact names the resolver reads.
|
|
37
|
+
*
|
|
38
|
+
* @type {ReadonlyArray<[string, string]>}
|
|
39
|
+
*/
|
|
40
|
+
const WINDOWS_WRAPPERS = [
|
|
41
|
+
["mvnw.cmd", "mvnwCmd"],
|
|
42
|
+
["gradlew.bat", "gradlewBat"],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Python managers detected through the lock file that owns them. Poetry is
|
|
47
|
+
* completed by the build backend its pyproject.toml declares, because a
|
|
48
|
+
* stray poetry.lock alone does not make a project poetry-managed.
|
|
49
|
+
*
|
|
50
|
+
* @type {Readonly<Record<string, string>>}
|
|
51
|
+
*/
|
|
52
|
+
const MANAGER_LOCK_FILES = Object.freeze({
|
|
53
|
+
"uv.lock": "uv",
|
|
54
|
+
"poetry.lock": "poetry",
|
|
55
|
+
"pdm.lock": "pdm",
|
|
56
|
+
"Pipfile.lock": "pipenv",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* pyproject.toml build backends that settle which competing manager governs
|
|
61
|
+
* the project.
|
|
62
|
+
*
|
|
63
|
+
* @type {Readonly<Record<string, string>>}
|
|
64
|
+
*/
|
|
65
|
+
const BACKEND_MANAGERS = Object.freeze({
|
|
66
|
+
"poetry.core.masonry.api": "poetry",
|
|
67
|
+
"pdm.backend": "pdm",
|
|
68
|
+
"pdm.pep517.api": "pdm",
|
|
69
|
+
uv: "uv",
|
|
70
|
+
uv_build: "uv",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Facts gathered so far, keyed by the project root they describe. */
|
|
74
|
+
const factsCache = new Map();
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a path exists and, on POSIX, carries the executable bit for the
|
|
78
|
+
* current user. On Windows every readable file is runnable, so existence is
|
|
79
|
+
* the whole answer.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} filePath Path to check.
|
|
82
|
+
* @param {boolean} checkExecutable True when the executable bit matters.
|
|
83
|
+
* @returns {boolean} Usability of the path as an executable.
|
|
84
|
+
*/
|
|
85
|
+
function isUsableExecutable(filePath, checkExecutable) {
|
|
86
|
+
return checkExecutable
|
|
87
|
+
? safeIsExecutableSync(filePath)
|
|
88
|
+
: safeExistsSync(filePath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read the build backend a pyproject.toml declares, from its
|
|
93
|
+
* `[build-system]` section only, so a mention elsewhere cannot answer.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} pyprojectPath Path to pyproject.toml.
|
|
96
|
+
* @returns {string|undefined} The declared backend, when present.
|
|
97
|
+
*/
|
|
98
|
+
function declaredBuildBackend(pyprojectPath) {
|
|
99
|
+
let content;
|
|
100
|
+
try {
|
|
101
|
+
content = safeReadFileSync(pyprojectPath, "utf-8");
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
let inBuildSystem = false;
|
|
106
|
+
for (const line of content.split(/\r?\n/)) {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
if (trimmed.startsWith("[")) {
|
|
109
|
+
inBuildSystem = trimmed === "[build-system]";
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!inBuildSystem || !trimmed.startsWith("build-backend")) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const eq = trimmed.indexOf("=");
|
|
116
|
+
if (eq === -1) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
return trimmed
|
|
120
|
+
.slice(eq + 1)
|
|
121
|
+
.trim()
|
|
122
|
+
.replace(/^["']|["']$/g, "")
|
|
123
|
+
.trim();
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a pyproject.toml declares a `[tool.<name>]` section, so a
|
|
130
|
+
* manager's own configuration marks the project as its own.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} pyprojectPath Path to pyproject.toml.
|
|
133
|
+
* @param {string} toolName Tool section name, e.g. `poetry`.
|
|
134
|
+
* @returns {boolean} True when the section is present.
|
|
135
|
+
*/
|
|
136
|
+
function hasToolSection(pyprojectPath, toolName) {
|
|
137
|
+
let content;
|
|
138
|
+
try {
|
|
139
|
+
content = safeReadFileSync(pyprojectPath, "utf-8");
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const section = `[tool.${toolName}]`;
|
|
144
|
+
for (const line of content.split(/\r?\n/)) {
|
|
145
|
+
const trimmed = line.trim();
|
|
146
|
+
if (trimmed === section || trimmed.startsWith(`${section.slice(0, -1)}.`)) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Detect the Python manager in play at a project root: the manager whose
|
|
155
|
+
* lock file is present, with poetry confirmed by the `[tool.poetry]`
|
|
156
|
+
* section its pyproject.toml declares. Two competing managers are settled
|
|
157
|
+
* by the declared build backend; when it settles nothing, both candidates
|
|
158
|
+
* travel in the facts so the report can ask instead of guessing.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} projectRoot Directory to inspect.
|
|
161
|
+
* @returns {{manager: string|undefined, candidates: string[]}} The manager in play and every candidate detected.
|
|
162
|
+
*/
|
|
163
|
+
function detectPythonManager(projectRoot) {
|
|
164
|
+
const pyprojectPath = join(projectRoot, "pyproject.toml");
|
|
165
|
+
const hasPyproject = safeExistsSync(pyprojectPath);
|
|
166
|
+
const candidates = [];
|
|
167
|
+
for (const [lockName, manager] of Object.entries(MANAGER_LOCK_FILES)) {
|
|
168
|
+
if (!safeExistsSync(join(projectRoot, lockName))) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (manager === "poetry") {
|
|
172
|
+
if (!hasPyproject || !hasToolSection(pyprojectPath, "poetry")) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!candidates.includes(manager)) {
|
|
177
|
+
candidates.push(manager);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const ordered = candidates.sort();
|
|
181
|
+
if (ordered.length <= 1) {
|
|
182
|
+
return { manager: ordered[0], candidates: ordered };
|
|
183
|
+
}
|
|
184
|
+
const backend = hasPyproject
|
|
185
|
+
? declaredBuildBackend(pyprojectPath)
|
|
186
|
+
: undefined;
|
|
187
|
+
const settled = backend ? BACKEND_MANAGERS[backend] : undefined;
|
|
188
|
+
if (settled && ordered.includes(settled)) {
|
|
189
|
+
return { manager: settled, candidates: ordered };
|
|
190
|
+
}
|
|
191
|
+
return { manager: undefined, candidates: ordered };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Detect the npm client a project root uses: the `packageManager` field of
|
|
196
|
+
* its package.json when the project names one, else the lock file present,
|
|
197
|
+
* else npm.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} projectRoot Directory to inspect.
|
|
200
|
+
* @returns {string} The npm client name.
|
|
201
|
+
*/
|
|
202
|
+
function detectNpmClient(projectRoot) {
|
|
203
|
+
const packageJsonPath = join(projectRoot, "package.json");
|
|
204
|
+
if (safeExistsSync(packageJsonPath)) {
|
|
205
|
+
try {
|
|
206
|
+
const packageJson = JSON.parse(
|
|
207
|
+
safeReadFileSync(packageJsonPath, "utf-8"),
|
|
208
|
+
);
|
|
209
|
+
const pinned = `${packageJson?.packageManager || ""}`;
|
|
210
|
+
const at = pinned.indexOf("@");
|
|
211
|
+
const name = (at > 0 ? pinned.slice(0, at) : pinned).trim();
|
|
212
|
+
if (name) {
|
|
213
|
+
return name;
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// An unreadable package.json leaves the lock files to answer.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const lockClients = [
|
|
220
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
221
|
+
["yarn.lock", "yarn"],
|
|
222
|
+
["bun.lockb", "bun"],
|
|
223
|
+
["bun.lock", "bun"],
|
|
224
|
+
["deno.lock", "deno"],
|
|
225
|
+
["package-lock.json", "npm"],
|
|
226
|
+
];
|
|
227
|
+
for (const [lockName, client] of lockClients) {
|
|
228
|
+
if (safeExistsSync(join(projectRoot, lockName))) {
|
|
229
|
+
return client;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return "npm";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Gather the command-shaping facts for a project root. The result is cached
|
|
237
|
+
* per root, so repeated scoring passes over the same project read the
|
|
238
|
+
* filesystem once.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} projectRoot Directory that was scanned.
|
|
241
|
+
* @param {Object} [options] Test hooks.
|
|
242
|
+
* @param {"posix"|"windows"} [options.platform] Platform override; defaults to the runtime platform.
|
|
243
|
+
* @returns {import("./shapeCommand.js").CommandFacts} Facts for the resolver.
|
|
244
|
+
*/
|
|
245
|
+
export function collectCommandFacts(projectRoot, options = {}) {
|
|
246
|
+
const platform =
|
|
247
|
+
options.platform ||
|
|
248
|
+
(globalThis.process?.platform === "win32" ? "windows" : "posix");
|
|
249
|
+
const cacheKey = `${platform}\n${projectRoot || ""}`;
|
|
250
|
+
const cached = factsCache.get(cacheKey);
|
|
251
|
+
if (cached) {
|
|
252
|
+
return cached;
|
|
253
|
+
}
|
|
254
|
+
const facts = {
|
|
255
|
+
projectRoot: projectRoot || "",
|
|
256
|
+
platform,
|
|
257
|
+
wrappers: {},
|
|
258
|
+
pythonManager: undefined,
|
|
259
|
+
pythonManagerCandidates: [],
|
|
260
|
+
npmClient: "npm",
|
|
261
|
+
};
|
|
262
|
+
if (projectRoot && safeExistsSync(projectRoot)) {
|
|
263
|
+
const checkExecutable = platform === "posix";
|
|
264
|
+
for (const [fileName, factName] of POSIX_WRAPPERS) {
|
|
265
|
+
const wrapperPath = join(projectRoot, fileName);
|
|
266
|
+
if (isUsableExecutable(wrapperPath, checkExecutable)) {
|
|
267
|
+
facts.wrappers[factName] = true;
|
|
268
|
+
} else if (safeExistsSync(wrapperPath)) {
|
|
269
|
+
facts.wrappers[`${factName}Inexecutable`] = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const [fileName, factName] of WINDOWS_WRAPPERS) {
|
|
273
|
+
if (isUsableExecutable(join(projectRoot, fileName), false)) {
|
|
274
|
+
facts.wrappers[factName] = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const python = detectPythonManager(projectRoot);
|
|
278
|
+
facts.pythonManager = python.manager;
|
|
279
|
+
facts.pythonManagerCandidates = python.candidates;
|
|
280
|
+
facts.npmClient = detectNpmClient(projectRoot);
|
|
281
|
+
}
|
|
282
|
+
factsCache.set(cacheKey, facts);
|
|
283
|
+
return facts;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Forget the gathered facts. Test hook; a production run gathers once.
|
|
288
|
+
*
|
|
289
|
+
* @returns {void}
|
|
290
|
+
*/
|
|
291
|
+
export function clearCommandFactsCache() {
|
|
292
|
+
factsCache.clear();
|
|
293
|
+
}
|