@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,1214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reflection: join what cdxgen did (the build ledger) with what it produced
|
|
3
|
+
* (the BOM, through the build-fidelity rule pack) and commit to a per-
|
|
4
|
+
* ecosystem fidelity verdict.
|
|
5
|
+
*
|
|
6
|
+
* The two evidence sources are kept independent on purpose. A finding backed
|
|
7
|
+
* by both the ledger and a BOM-side rule is high confidence; either alone is
|
|
8
|
+
* lower confidence. A ledger event with no BOM-side corroboration is reported
|
|
9
|
+
* as an observation, never as a defect: an event says the run degraded
|
|
10
|
+
* somewhere, but only the BOM shows whether the output paid for it. The
|
|
11
|
+
* rule-only path is a first-class path — reflecting over a foreign BOM with
|
|
12
|
+
* no ledger at all still produces a complete verdict.
|
|
13
|
+
*
|
|
14
|
+
* Every ecosystem present in the scan is assigned exactly one tier from the
|
|
15
|
+
* ladder `resolved > lockfile > manifest > heuristic > absent`, or excluded
|
|
16
|
+
* from scoring entirely as `unsupported` when its markers name an ecosystem
|
|
17
|
+
* cdxgen has no project type for. Ties break toward the worse tier: a report
|
|
18
|
+
* that overstates fidelity stops the remediation loop early, while one that
|
|
19
|
+
* understates it merely costs an extra iteration.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
23
|
+
import { basename, join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { Purl } from "@cdxgen/cdx-purl";
|
|
26
|
+
|
|
27
|
+
import { readEnvironmentVariable } from "../../../core/activity.js";
|
|
28
|
+
import {
|
|
29
|
+
getLedgerEvents,
|
|
30
|
+
LEDGER_EVENT_IMPACTS,
|
|
31
|
+
LEDGER_EVENT_KINDS,
|
|
32
|
+
LEDGER_TOOL_ECOSYSTEM,
|
|
33
|
+
loadLedgerFile,
|
|
34
|
+
} from "../../../core/buildLedger.js";
|
|
35
|
+
import {
|
|
36
|
+
isVersionedJvmToolProjectType,
|
|
37
|
+
PROJECT_TYPE_ALIASES,
|
|
38
|
+
} from "../../../core/env.js";
|
|
39
|
+
import { dirNameStr } from "../../../core/paths.js";
|
|
40
|
+
import { CDXGEN_VERSION } from "../../../core/state.js";
|
|
41
|
+
import { evaluateRules, loadRules } from "../ruleEngine.js";
|
|
42
|
+
import {
|
|
43
|
+
findRuntimeToolMismatch,
|
|
44
|
+
readFormulationEvidence,
|
|
45
|
+
TOOL_NAME_ECOSYSTEMS,
|
|
46
|
+
} from "./formulationEvidence.js";
|
|
47
|
+
import { detectEcosystemMarkers, MARKERS_BY_NAME } from "./markers.js";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The fidelity tier ladder, from healthiest to worst.
|
|
51
|
+
*
|
|
52
|
+
* @type {string[]}
|
|
53
|
+
*/
|
|
54
|
+
export const FIDELITY_TIERS = [
|
|
55
|
+
"resolved",
|
|
56
|
+
"lockfile",
|
|
57
|
+
"manifest",
|
|
58
|
+
"heuristic",
|
|
59
|
+
"absent",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Ladder position of each tier; higher is worse. */
|
|
63
|
+
const TIER_SEVERITY = new Map([
|
|
64
|
+
["resolved", 0],
|
|
65
|
+
["lockfile", 1],
|
|
66
|
+
["manifest", 2],
|
|
67
|
+
["heuristic", 3],
|
|
68
|
+
["absent", 4],
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ecosystem states. `graded` rows carry a tier below their ceiling and are
|
|
73
|
+
* the remediation loop's business; `at-ceiling` rows already parse as
|
|
74
|
+
* completely as their ecosystem permits; `absent` rows produced zero
|
|
75
|
+
* components for a supported ecosystem and are real failures; `unsupported`
|
|
76
|
+
* rows are coverage gaps in cdxgen itself, excluded from the score.
|
|
77
|
+
*
|
|
78
|
+
* @type {string[]}
|
|
79
|
+
*/
|
|
80
|
+
export const ECOSYSTEM_STATES = [
|
|
81
|
+
"graded",
|
|
82
|
+
"at-ceiling",
|
|
83
|
+
"absent",
|
|
84
|
+
"unsupported",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ecosystems whose best achievable tier is below `resolved`, because the
|
|
89
|
+
* ecosystem has no resolved graph for cdxgen to read. Any ecosystem not
|
|
90
|
+
* listed here defaults to a ceiling of `resolved`, so a newly supported
|
|
91
|
+
* ecosystem fails safe by demanding the most. Every entry below is backed by
|
|
92
|
+
* a measurement recorded in the deliverable handoff; none is added from
|
|
93
|
+
* reading a table.
|
|
94
|
+
*
|
|
95
|
+
* @type {Readonly<Record<string, string>>}
|
|
96
|
+
*/
|
|
97
|
+
export const ECOSYSTEM_CEILINGS = Object.freeze({
|
|
98
|
+
// Helm charts carry pinned dependencies in Chart.yaml with no resolver to
|
|
99
|
+
// run; measured 52 components / 0 dependency nodes on helm-charts.
|
|
100
|
+
helm: "manifest",
|
|
101
|
+
// deps.edn and project.clj pin exact versions; measured 23 components /
|
|
102
|
+
// 0 dependency nodes on babashka.
|
|
103
|
+
clojure: "manifest",
|
|
104
|
+
// cdxgen parses Package.resolved but never builds a Swift dependency
|
|
105
|
+
// graph; measured 3 components / 0 dependency nodes with a byte-identical
|
|
106
|
+
// BOM whether or not the swift toolchain was available.
|
|
107
|
+
swift: "lockfile",
|
|
108
|
+
// pubspec.lock records only a direct/transitive classification per package
|
|
109
|
+
// and no cdxgen code path invokes a dart toolchain, so no environment can
|
|
110
|
+
// add the missing graph; measured 344 components / 1 dependency node on
|
|
111
|
+
// spotube, the parser's maximum for the format.
|
|
112
|
+
dart: "manifest",
|
|
113
|
+
// The npm, rust and python parsers derive every BOM they emit from a
|
|
114
|
+
// lockfile or an equivalent pinned source, so the components always carry
|
|
115
|
+
// the lockfile-only evidence the checks below key on - npm integrity
|
|
116
|
+
// strings, Cargo.lock checksums, cdx:pypi provenance properties - whether
|
|
117
|
+
// or not the dependencies were installed. `resolved` is therefore not a
|
|
118
|
+
// state any environment can produce evidence for in these ecosystems; the
|
|
119
|
+
// graph itself is still guarded by the build-fidelity rules, which demote
|
|
120
|
+
// to `manifest` the moment coverage drops.
|
|
121
|
+
npm: "lockfile",
|
|
122
|
+
python: "lockfile",
|
|
123
|
+
rust: "lockfile",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Canonical ecosystem names used by the ledger's producers and by this
|
|
128
|
+
* module's rows, mapped from the PROJECT_TYPE_ALIASES keys that describe the
|
|
129
|
+
* same ecosystem. Ecosystems absent from this map (containers, osquery,
|
|
130
|
+
* universal and similar non-package types) never get a fidelity row.
|
|
131
|
+
*
|
|
132
|
+
* @type {Readonly<Record<string, string>>}
|
|
133
|
+
*/
|
|
134
|
+
const ECOSYSTEM_ALIAS_KEYS = Object.freeze({
|
|
135
|
+
java: "java",
|
|
136
|
+
js: "npm",
|
|
137
|
+
py: "python",
|
|
138
|
+
go: "go",
|
|
139
|
+
rust: "rust",
|
|
140
|
+
php: "php",
|
|
141
|
+
ruby: "ruby",
|
|
142
|
+
csharp: "csharp",
|
|
143
|
+
dart: "dart",
|
|
144
|
+
helm: "helm",
|
|
145
|
+
clojure: "clojure",
|
|
146
|
+
swift: "swift",
|
|
147
|
+
cocoa: "cocoa",
|
|
148
|
+
elixir: "elixir",
|
|
149
|
+
c: "c",
|
|
150
|
+
haskell: "haskell",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/** Alias or project type → canonical ecosystem. The first assignment of an
|
|
154
|
+
* alias wins, so an alias claimed by two ecosystems (e.g. `swift` under both
|
|
155
|
+
* swift and cocoa) keeps the more specific ecosystem. */
|
|
156
|
+
const ALIAS_TO_ECOSYSTEM = new Map();
|
|
157
|
+
for (const [key, canonical] of Object.entries(ECOSYSTEM_ALIAS_KEYS)) {
|
|
158
|
+
for (const alias of [key, ...(PROJECT_TYPE_ALIASES[key] || [])]) {
|
|
159
|
+
const normalizedAlias = alias.toLowerCase();
|
|
160
|
+
if (!ALIAS_TO_ECOSYSTEM.has(normalizedAlias)) {
|
|
161
|
+
ALIAS_TO_ECOSYSTEM.set(normalizedAlias, canonical);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every project type cdxgen recognizes, keys and aliases alike. An ecosystem
|
|
168
|
+
* outside {@link ECOSYSTEM_ALIAS_KEYS} but present here — containers, os,
|
|
169
|
+
* universal, github, binary — is supported and simply has no package-fidelity
|
|
170
|
+
* ladder, so it gets no row at all. Only a name cdxgen cannot dispatch on is
|
|
171
|
+
* a coverage gap.
|
|
172
|
+
*
|
|
173
|
+
* @type {Set<string>}
|
|
174
|
+
*/
|
|
175
|
+
const KNOWN_PROJECT_TYPES = new Set();
|
|
176
|
+
for (const [key, aliases] of Object.entries(PROJECT_TYPE_ALIASES)) {
|
|
177
|
+
KNOWN_PROJECT_TYPES.add(key.toLowerCase());
|
|
178
|
+
for (const alias of aliases) {
|
|
179
|
+
KNOWN_PROJECT_TYPES.add(alias.toLowerCase());
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Purl type → canonical ecosystem for every type the build-fidelity pack and
|
|
185
|
+
* the ledger's producers name. Components with other purl types never form a
|
|
186
|
+
* fidelity row.
|
|
187
|
+
*
|
|
188
|
+
* @type {Readonly<Record<string, string>>}
|
|
189
|
+
*/
|
|
190
|
+
const PURL_TYPE_ECOSYSTEMS = Object.freeze({
|
|
191
|
+
maven: "java",
|
|
192
|
+
npm: "npm",
|
|
193
|
+
pypi: "python",
|
|
194
|
+
golang: "go",
|
|
195
|
+
cargo: "rust",
|
|
196
|
+
gem: "ruby",
|
|
197
|
+
nuget: "csharp",
|
|
198
|
+
composer: "php",
|
|
199
|
+
pub: "dart",
|
|
200
|
+
helm: "helm",
|
|
201
|
+
clojars: "clojure",
|
|
202
|
+
swift: "swift",
|
|
203
|
+
cocoapods: "cocoa",
|
|
204
|
+
hex: "elixir",
|
|
205
|
+
conan: "c",
|
|
206
|
+
hackage: "haskell",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/** Canonical ecosystem → the purl types that belong to it. */
|
|
210
|
+
const ECOSYSTEM_PURL_TYPES = new Map();
|
|
211
|
+
for (const [purlType, ecosystem] of Object.entries(PURL_TYPE_ECOSYSTEMS)) {
|
|
212
|
+
const types = ECOSYSTEM_PURL_TYPES.get(ecosystem) || [];
|
|
213
|
+
types.push(purlType);
|
|
214
|
+
ECOSYSTEM_PURL_TYPES.set(ecosystem, types);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Ledger event kinds whose event says the run degraded. `tool.missing` and
|
|
219
|
+
* `tool.mismatch` are excluded: they are tool facts first and live in the
|
|
220
|
+
* row's toolsMissing/toolsMismatched arrays.
|
|
221
|
+
*
|
|
222
|
+
* @type {Set<string>}
|
|
223
|
+
*/
|
|
224
|
+
const DEGRADATION_KINDS = new Set([
|
|
225
|
+
LEDGER_EVENT_KINDS.FALLBACK_ENGAGED,
|
|
226
|
+
LEDGER_EVENT_KINDS.EVIDENCE_DEGRADED,
|
|
227
|
+
LEDGER_EVENT_KINDS.COMMAND_FAILED,
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Per-ecosystem checks for lockfile evidence: BOM features that only a
|
|
232
|
+
* lockfile parse produces. The tier only reaches `lockfile` when no demoting
|
|
233
|
+
* rule or event fired, so a lockfile marker on an unhealthy BOM never hides
|
|
234
|
+
* a demotion. For the ecosystems whose ceiling is `lockfile` this evidence is
|
|
235
|
+
* what carries a healthy row onto its ceiling.
|
|
236
|
+
*
|
|
237
|
+
* @type {Readonly<Record<string, (bomJson: Object, facts: Object) => boolean>>}
|
|
238
|
+
*/
|
|
239
|
+
const LOCKFILE_EVIDENCE_CHECKS = Object.freeze({
|
|
240
|
+
// npm lockfiles are the only npm source that carries integrity strings.
|
|
241
|
+
npm: (_bomJson, facts) => facts.hashesByEcosystem.get("npm") === true,
|
|
242
|
+
// Cargo.lock checksums are the only cargo source that carries hashes.
|
|
243
|
+
rust: (_bomJson, facts) => facts.hashesByEcosystem.get("rust") === true,
|
|
244
|
+
// Python parses record per-package provenance properties only when a
|
|
245
|
+
// requirements file or a lockfile was read.
|
|
246
|
+
python: (_bomJson, facts) =>
|
|
247
|
+
facts.provenanceByEcosystem.get("python") === true,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/** Degradation kinds eligible to become remediation entries, joined with the
|
|
251
|
+
* rule findings that corroborate them. */
|
|
252
|
+
const REMEDIABLE_KINDS = DEGRADATION_KINDS;
|
|
253
|
+
|
|
254
|
+
/** Fidelity rules from data/rules/build-fidelity.yaml, loaded once. */
|
|
255
|
+
let fidelityRulesPromise;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* True when introspection is enabled for a run: the CLI option is set or the
|
|
259
|
+
* documented environment opt-in is present. Everything in this module sits
|
|
260
|
+
* behind this check, so a disabled run pays one boolean test.
|
|
261
|
+
*
|
|
262
|
+
* @param {Object} options CLI options.
|
|
263
|
+
* @returns {boolean} True when the reflection should run.
|
|
264
|
+
*/
|
|
265
|
+
export { isIntrospectionEnabled } from "../../../core/buildLedger.js";
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolve the fidelity rule pack, loading it once per process.
|
|
269
|
+
*
|
|
270
|
+
* @returns {Promise<Object[]>} Rules in the build-fidelity category.
|
|
271
|
+
*/
|
|
272
|
+
async function getFidelityRules() {
|
|
273
|
+
if (!fidelityRulesPromise) {
|
|
274
|
+
fidelityRulesPromise = loadRules(join(dirNameStr, "data", "rules")).then(
|
|
275
|
+
(rules) => rules.filter((rule) => rule.category === "build-fidelity"),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return fidelityRulesPromise;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Extract the purl type from a purl string, matching the segment the
|
|
283
|
+
* build-fidelity conditions gate on. Falls back to the same substring split
|
|
284
|
+
* the rule conditions use when the purl cannot be parsed.
|
|
285
|
+
*
|
|
286
|
+
* @param {string|undefined} purl Package URL.
|
|
287
|
+
* @returns {string|undefined} Purl type, or undefined when absent.
|
|
288
|
+
*/
|
|
289
|
+
function purlTypeOf(purl) {
|
|
290
|
+
if (!purl || typeof purl !== "string") {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
return Purl.parse(purl)?.type || undefined;
|
|
295
|
+
} catch {
|
|
296
|
+
const schemeIndex = purl.indexOf("pkg:");
|
|
297
|
+
if (schemeIndex < 0) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
return purl.slice(schemeIndex + 4).split("/")[0] || undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Collect the BOM facts the verdict reads: components and dependency edges
|
|
306
|
+
* per canonical ecosystem, lockfile evidence markers, and the parent
|
|
307
|
+
* component's purl type.
|
|
308
|
+
*
|
|
309
|
+
* @param {Object} bomJson CycloneDX BOM.
|
|
310
|
+
* @returns {Object} Aggregated facts.
|
|
311
|
+
*/
|
|
312
|
+
function collectBomFacts(bomJson) {
|
|
313
|
+
const components = Array.isArray(bomJson?.components)
|
|
314
|
+
? bomJson.components
|
|
315
|
+
: [];
|
|
316
|
+
const dependencies = Array.isArray(bomJson?.dependencies)
|
|
317
|
+
? bomJson.dependencies
|
|
318
|
+
: [];
|
|
319
|
+
const componentCountByEcosystem = new Map();
|
|
320
|
+
const hashesByEcosystem = new Map();
|
|
321
|
+
const provenanceByEcosystem = new Map();
|
|
322
|
+
const refEcosystem = new Map();
|
|
323
|
+
for (const component of components) {
|
|
324
|
+
const purlType = purlTypeOf(component.purl);
|
|
325
|
+
const ecosystem = PURL_TYPE_ECOSYSTEMS[purlType];
|
|
326
|
+
if (!ecosystem) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
componentCountByEcosystem.set(
|
|
330
|
+
ecosystem,
|
|
331
|
+
(componentCountByEcosystem.get(ecosystem) || 0) + 1,
|
|
332
|
+
);
|
|
333
|
+
if (component["bom-ref"]) {
|
|
334
|
+
refEcosystem.set(component["bom-ref"], ecosystem);
|
|
335
|
+
}
|
|
336
|
+
if (Array.isArray(component.hashes) && component.hashes.length) {
|
|
337
|
+
hashesByEcosystem.set(ecosystem, true);
|
|
338
|
+
}
|
|
339
|
+
if (
|
|
340
|
+
Array.isArray(component.properties) &&
|
|
341
|
+
component.properties.some((property) =>
|
|
342
|
+
property?.name?.startsWith(`cdx:${purlType}:`),
|
|
343
|
+
)
|
|
344
|
+
) {
|
|
345
|
+
provenanceByEcosystem.set(ecosystem, true);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const edgeCountByEcosystem = new Map();
|
|
349
|
+
let totalEdgeCount = 0;
|
|
350
|
+
for (const dependency of dependencies) {
|
|
351
|
+
const edgeCount = Array.isArray(dependency.dependsOn)
|
|
352
|
+
? dependency.dependsOn.length
|
|
353
|
+
: 0;
|
|
354
|
+
totalEdgeCount += edgeCount;
|
|
355
|
+
const ecosystem = refEcosystem.get(dependency.ref);
|
|
356
|
+
if (ecosystem) {
|
|
357
|
+
edgeCountByEcosystem.set(
|
|
358
|
+
ecosystem,
|
|
359
|
+
(edgeCountByEcosystem.get(ecosystem) || 0) + edgeCount,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
componentCountByEcosystem,
|
|
365
|
+
edgeCountByEcosystem,
|
|
366
|
+
hashesByEcosystem,
|
|
367
|
+
provenanceByEcosystem,
|
|
368
|
+
refEcosystem,
|
|
369
|
+
totalEdgeCount,
|
|
370
|
+
dependencyCount: dependencies.length,
|
|
371
|
+
parentPurlType: purlTypeOf(bomJson?.metadata?.component?.purl),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Resolve the events the reflection joins against: the caller's override, the
|
|
377
|
+
* JSONL sidecar when configured, or the in-memory buffer. Prefer the sidecar
|
|
378
|
+
* whenever it exists — worker threads append there, so it is the only
|
|
379
|
+
* complete record of a multi-worker run.
|
|
380
|
+
*
|
|
381
|
+
* @param {Object} context Reflection overrides.
|
|
382
|
+
* @returns {{events: Object[], source: string}} Events and their origin.
|
|
383
|
+
*/
|
|
384
|
+
function resolveLedgerEvents(context) {
|
|
385
|
+
if (Array.isArray(context.ledgerEvents)) {
|
|
386
|
+
return {
|
|
387
|
+
events: context.ledgerEvents,
|
|
388
|
+
source: context.ledgerEvents.length ? "provided" : "none",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const sidecarPath = readEnvironmentVariable("CDXGEN_INTROSPECT_LEDGER");
|
|
392
|
+
if (sidecarPath) {
|
|
393
|
+
return { events: loadLedgerFile(sidecarPath), source: "sidecar" };
|
|
394
|
+
}
|
|
395
|
+
const events = getLedgerEvents();
|
|
396
|
+
return { events, source: events.length ? "memory" : "none" };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Resolve the host runtime from the ledger's runtime event when present,
|
|
401
|
+
* falling back to direct runtime detection. The ledger event is preferred
|
|
402
|
+
* because it is what the run itself recorded, and reading it avoids re-running
|
|
403
|
+
* the environment probes.
|
|
404
|
+
*
|
|
405
|
+
* @param {Object[]} events Ledger events.
|
|
406
|
+
* @returns {{name: string, version: string}} Runtime name and version.
|
|
407
|
+
*/
|
|
408
|
+
function resolveRuntime(events) {
|
|
409
|
+
const runtimeEvent = events.find(
|
|
410
|
+
(event) =>
|
|
411
|
+
event.kind === LEDGER_EVENT_KINDS.TOOL_RESOLVED &&
|
|
412
|
+
event.ecosystem === "generic" &&
|
|
413
|
+
event.source === "runtime",
|
|
414
|
+
);
|
|
415
|
+
if (runtimeEvent) {
|
|
416
|
+
const names = { node: "Node.js", deno: "Deno", bun: "Bun" };
|
|
417
|
+
return {
|
|
418
|
+
name: names[runtimeEvent.tool] || runtimeEvent.tool || "Unknown",
|
|
419
|
+
version: runtimeEvent.found || "N/A",
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (globalThis.Deno?.version?.deno !== undefined) {
|
|
423
|
+
return { name: "Deno", version: globalThis.Deno.version.deno };
|
|
424
|
+
}
|
|
425
|
+
if (globalThis.Bun?.version !== undefined) {
|
|
426
|
+
return { name: "Bun", version: globalThis.Bun.version };
|
|
427
|
+
}
|
|
428
|
+
if (globalThis.process?.versions?.node !== undefined) {
|
|
429
|
+
return { name: "Node.js", version: globalThis.process.versions.node };
|
|
430
|
+
}
|
|
431
|
+
return { name: "Unknown", version: "N/A" };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Drop a trailing version pin such as the `3.9.9` of `maven3.9.9`, scanning
|
|
436
|
+
* backwards over digits and dots so the cost stays linear in the length of
|
|
437
|
+
* the project type.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} projectType Lower-cased project type.
|
|
440
|
+
* @returns {string} The type without its trailing version characters.
|
|
441
|
+
*/
|
|
442
|
+
function withoutVersionSuffix(projectType) {
|
|
443
|
+
let end = projectType.length;
|
|
444
|
+
while (end > 0) {
|
|
445
|
+
const char = projectType[end - 1];
|
|
446
|
+
if (char !== "." && (char < "0" || char > "9")) {
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
end -= 1;
|
|
450
|
+
}
|
|
451
|
+
return projectType.slice(0, end);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Canonical ecosystem for a CLI project type, resolving versioned JVM tool
|
|
456
|
+
* pins such as `maven3.9.9` to their base ecosystem.
|
|
457
|
+
*
|
|
458
|
+
* @param {string} projectType Project type from the CLI options.
|
|
459
|
+
* @returns {string|undefined} Canonical ecosystem, or undefined for types outside the fidelity scope.
|
|
460
|
+
*/
|
|
461
|
+
function canonicalEcosystemFor(projectType) {
|
|
462
|
+
const normalizedType = `${projectType || ""}`.toLowerCase();
|
|
463
|
+
return (
|
|
464
|
+
ALIAS_TO_ECOSYSTEM.get(normalizedType) ||
|
|
465
|
+
(isVersionedJvmToolProjectType(normalizedType)
|
|
466
|
+
? ALIAS_TO_ECOSYSTEM.get(withoutVersionSuffix(normalizedType))
|
|
467
|
+
: undefined)
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Requested project types from the CLI options, accepting the string and
|
|
473
|
+
* array forms and comma-separated members.
|
|
474
|
+
*
|
|
475
|
+
* @param {Object} options CLI options.
|
|
476
|
+
* @returns {string[]} Requested project types.
|
|
477
|
+
*/
|
|
478
|
+
function requestedProjectTypes(options) {
|
|
479
|
+
const rawTypes = Array.isArray(options?.projectType)
|
|
480
|
+
? options.projectType
|
|
481
|
+
: options?.projectType
|
|
482
|
+
? [options.projectType]
|
|
483
|
+
: [];
|
|
484
|
+
return rawTypes
|
|
485
|
+
.flatMap((type) => `${type}`.split(","))
|
|
486
|
+
.map((type) => type.trim())
|
|
487
|
+
.filter(Boolean);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Attribute a rule finding to one ecosystem. Scoped rules are attributed to
|
|
492
|
+
* the ecosystem owning the most components among the purl types the rule
|
|
493
|
+
* applies to; the empty-component rule is attributed through the parent purl
|
|
494
|
+
* type it reports; purely structural rules stay global.
|
|
495
|
+
*
|
|
496
|
+
* @param {Object} finding Rule finding.
|
|
497
|
+
* @param {Object} ruleById Rule definitions keyed by id.
|
|
498
|
+
* @param {Object} facts Aggregated BOM facts.
|
|
499
|
+
* @returns {string|undefined} Canonical ecosystem, or undefined when the finding is global.
|
|
500
|
+
*/
|
|
501
|
+
function attributeFinding(finding, ruleById, facts) {
|
|
502
|
+
const rule = ruleById.get(finding.ruleId);
|
|
503
|
+
const appliesTo = Array.isArray(rule?.["applies-to"])
|
|
504
|
+
? rule["applies-to"]
|
|
505
|
+
: [];
|
|
506
|
+
if (appliesTo.length) {
|
|
507
|
+
const present = appliesTo
|
|
508
|
+
.map((purlType) => PURL_TYPE_ECOSYSTEMS[purlType])
|
|
509
|
+
.filter((ecosystem) => facts.componentCountByEcosystem.has(ecosystem))
|
|
510
|
+
.sort(
|
|
511
|
+
(a, b) =>
|
|
512
|
+
(facts.componentCountByEcosystem.get(b) || 0) -
|
|
513
|
+
(facts.componentCountByEcosystem.get(a) || 0) || a.localeCompare(b),
|
|
514
|
+
);
|
|
515
|
+
return present[0];
|
|
516
|
+
}
|
|
517
|
+
const reportedPurlType = finding._match?.parentPurlType;
|
|
518
|
+
if (reportedPurlType && PURL_TYPE_ECOSYSTEMS[reportedPurlType]) {
|
|
519
|
+
return PURL_TYPE_ECOSYSTEMS[reportedPurlType];
|
|
520
|
+
}
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Stable JSON serialization for fingerprints and dedup keys: object keys are
|
|
526
|
+
* sorted recursively so the result depends on values, never on insertion
|
|
527
|
+
* order.
|
|
528
|
+
*
|
|
529
|
+
* @param {*} value Value to serialize.
|
|
530
|
+
* @returns {string} Deterministic JSON text.
|
|
531
|
+
*/
|
|
532
|
+
function stableStringify(value) {
|
|
533
|
+
if (Array.isArray(value)) {
|
|
534
|
+
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
535
|
+
}
|
|
536
|
+
if (value && typeof value === "object") {
|
|
537
|
+
const keys = Object.keys(value).sort();
|
|
538
|
+
return `{${keys
|
|
539
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
540
|
+
.join(",")}}`;
|
|
541
|
+
}
|
|
542
|
+
return JSON.stringify(value);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Hash the inputs that must change before a re-run can improve the verdict:
|
|
547
|
+
* the scanned path, the requested project types, the *resolved* tool versions
|
|
548
|
+
* the run observed, and the cdxgen version. Expected versions are excluded on
|
|
549
|
+
* purpose — the fingerprint exists so an agent loop can detect that an
|
|
550
|
+
* install took effect, and only found versions move when it did.
|
|
551
|
+
*
|
|
552
|
+
* @param {Object} params Fingerprint inputs.
|
|
553
|
+
* @param {string} params.projectPath Scanned directory.
|
|
554
|
+
* @param {string[]} params.projectTypes Requested project types.
|
|
555
|
+
* @param {Object[]} params.events Ledger events.
|
|
556
|
+
* @returns {string} Hex sha256 of the canonical inputs.
|
|
557
|
+
*/
|
|
558
|
+
function computeInputsFingerprint({ projectPath, projectTypes, events }) {
|
|
559
|
+
const resolvedTools = [];
|
|
560
|
+
const seenTools = new Set();
|
|
561
|
+
for (const event of events) {
|
|
562
|
+
if (
|
|
563
|
+
event.kind !== LEDGER_EVENT_KINDS.TOOL_RESOLVED ||
|
|
564
|
+
!event.tool ||
|
|
565
|
+
event.found === undefined ||
|
|
566
|
+
event.ecosystem === LEDGER_TOOL_ECOSYSTEM
|
|
567
|
+
) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const key = `${event.ecosystem}|${event.tool}|${event.found}`;
|
|
571
|
+
if (seenTools.has(key)) {
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
seenTools.add(key);
|
|
575
|
+
resolvedTools.push({
|
|
576
|
+
ecosystem: event.ecosystem,
|
|
577
|
+
tool: event.tool,
|
|
578
|
+
found: event.found,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
resolvedTools.sort((a, b) =>
|
|
582
|
+
`${a.ecosystem}|${a.tool}|${a.found}`.localeCompare(
|
|
583
|
+
`${b.ecosystem}|${b.tool}|${b.found}`,
|
|
584
|
+
),
|
|
585
|
+
);
|
|
586
|
+
const payload = stableStringify({
|
|
587
|
+
projectPath: projectPath || "",
|
|
588
|
+
projectTypes: [...projectTypes].sort(),
|
|
589
|
+
resolvedTools,
|
|
590
|
+
cdxgenVersion: CDXGEN_VERSION,
|
|
591
|
+
});
|
|
592
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Deduplicate row tool facts by their full tuple so repeated probes of the
|
|
597
|
+
* same tool collapse into one entry.
|
|
598
|
+
*
|
|
599
|
+
* @param {Object[]} entries Tool fact entries.
|
|
600
|
+
* @returns {Object[]} Deduplicated entries in first-seen order.
|
|
601
|
+
*/
|
|
602
|
+
function dedupeToolEntries(entries) {
|
|
603
|
+
const seen = new Set();
|
|
604
|
+
const deduped = [];
|
|
605
|
+
for (const entry of entries) {
|
|
606
|
+
const key = stableStringify(entry);
|
|
607
|
+
if (seen.has(key)) {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
seen.add(key);
|
|
611
|
+
deduped.push(entry);
|
|
612
|
+
}
|
|
613
|
+
return deduped;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Tier signal a finding's rule declares, resolved through the rule pack
|
|
618
|
+
* because findings do not carry pack metadata.
|
|
619
|
+
*
|
|
620
|
+
* @param {Object} finding Rule finding.
|
|
621
|
+
* @param {Object} ruleById Rule definitions keyed by id.
|
|
622
|
+
* @returns {string|undefined} The rule's tier-signal, when declared.
|
|
623
|
+
*/
|
|
624
|
+
function tierSignal(finding, ruleById) {
|
|
625
|
+
return ruleById.get(finding.ruleId)?.["tier-signal"];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The evidence source a finding's tier reason is attributed to. Rules whose
|
|
630
|
+
* findings read the BOM's formulation carry `reason-source: formulation`;
|
|
631
|
+
* everything else reasons from the rule pack's BOM-structural match.
|
|
632
|
+
*
|
|
633
|
+
* @param {Object} finding Rule finding.
|
|
634
|
+
* @param {Object} ruleById Rule definitions keyed by id.
|
|
635
|
+
* @returns {string} The reason's source label.
|
|
636
|
+
*/
|
|
637
|
+
function reasonSourceOf(finding, ruleById) {
|
|
638
|
+
return ruleById.get(finding.ruleId)?.["reason-source"] || "rule";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Compact observation record for an event that is reported but never scored.
|
|
643
|
+
* The failure facts a remediation would need — the command, its exit code, a
|
|
644
|
+
* bounded output excerpt and the diagnosed cause — travel with the record, so
|
|
645
|
+
* an observation explains itself as well as a remediation does.
|
|
646
|
+
*
|
|
647
|
+
* @param {Object} event Ledger event.
|
|
648
|
+
* @returns {Object} Observation.
|
|
649
|
+
*/
|
|
650
|
+
function observationFromEvent(event) {
|
|
651
|
+
return {
|
|
652
|
+
kind: event.kind,
|
|
653
|
+
ecosystem: event.ecosystem,
|
|
654
|
+
tool: event.tool,
|
|
655
|
+
remediationId: event.remediationId,
|
|
656
|
+
impact: event.impact,
|
|
657
|
+
detail: event.detail,
|
|
658
|
+
command: event.command,
|
|
659
|
+
exitCode: typeof event.exitCode === "number" ? event.exitCode : undefined,
|
|
660
|
+
causeDetail: event.causeDetail,
|
|
661
|
+
outputExcerpt: event.outputExcerpt,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The failure facts one degradation record carries alongside its remediation
|
|
667
|
+
* id and impact, taken from the event that reported it. Every field is
|
|
668
|
+
* optional: a producer that had no output in hand records none.
|
|
669
|
+
*
|
|
670
|
+
* @param {Object} event Ledger event.
|
|
671
|
+
* @returns {Object} Failure facts, with absent fields left undefined.
|
|
672
|
+
*/
|
|
673
|
+
function failureFactsFromEvent(event) {
|
|
674
|
+
return {
|
|
675
|
+
command: event.command,
|
|
676
|
+
exitCode: typeof event.exitCode === "number" ? event.exitCode : undefined,
|
|
677
|
+
causeDetail: event.causeDetail,
|
|
678
|
+
outputExcerpt: event.outputExcerpt,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Build one ecosystem's assessment: tool facts, corroborated degradations,
|
|
684
|
+
* findings, and the tier assigned by the deterministic ladder. Every
|
|
685
|
+
* contributing reason is recorded, and the reasons behind the winning tier
|
|
686
|
+
* are marked determining.
|
|
687
|
+
*
|
|
688
|
+
* @param {Object} row Row inputs.
|
|
689
|
+
* @param {string} row.ecosystem Canonical ecosystem.
|
|
690
|
+
* @param {boolean} row.unsupported True when cdxgen has no project type for the ecosystem.
|
|
691
|
+
* @param {Object[]} row.events Ledger events for this ecosystem.
|
|
692
|
+
* @param {Object[]} row.findings Rule findings attributed to this ecosystem.
|
|
693
|
+
* @param {Object} row.ruleById Rule definitions keyed by id.
|
|
694
|
+
* @param {string[]} row.markersOnDisk Marker paths found on disk or in ledger event paths.
|
|
695
|
+
* @param {number} row.componentCount Components of this ecosystem in the BOM.
|
|
696
|
+
* @param {number} row.dependencyEdgeCount Dependency edges anchored in this ecosystem.
|
|
697
|
+
* @param {Object[]} [row.formulationTools] Tool facts the BOM's formulation records for this ecosystem, used only when no ledger observed the run.
|
|
698
|
+
* @param {Object} bomJson CycloneDX BOM.
|
|
699
|
+
* @param {Object} facts Aggregated BOM facts.
|
|
700
|
+
* @returns {{assessment: Object, observed: Object[]}} The assessment and the events routed to observations.
|
|
701
|
+
*/
|
|
702
|
+
function assessEcosystem(row, bomJson, facts) {
|
|
703
|
+
const observed = [];
|
|
704
|
+
const toolsExpected = [];
|
|
705
|
+
const toolsResolved = [];
|
|
706
|
+
const toolsMissing = [];
|
|
707
|
+
const toolsMismatched = [];
|
|
708
|
+
for (const event of row.events) {
|
|
709
|
+
switch (event.kind) {
|
|
710
|
+
case LEDGER_EVENT_KINDS.TOOL_EXPECTED:
|
|
711
|
+
toolsExpected.push({
|
|
712
|
+
tool: event.tool,
|
|
713
|
+
wanted: event.wanted,
|
|
714
|
+
source: event.source,
|
|
715
|
+
});
|
|
716
|
+
break;
|
|
717
|
+
case LEDGER_EVENT_KINDS.TOOL_RESOLVED:
|
|
718
|
+
toolsResolved.push({
|
|
719
|
+
tool: event.tool,
|
|
720
|
+
found: event.found,
|
|
721
|
+
source: event.source,
|
|
722
|
+
path: event.path,
|
|
723
|
+
});
|
|
724
|
+
break;
|
|
725
|
+
case LEDGER_EVENT_KINDS.TOOL_MISSING:
|
|
726
|
+
toolsMissing.push({
|
|
727
|
+
tool: event.tool,
|
|
728
|
+
wanted: event.wanted,
|
|
729
|
+
source: event.source,
|
|
730
|
+
});
|
|
731
|
+
break;
|
|
732
|
+
case LEDGER_EVENT_KINDS.TOOL_MISMATCH:
|
|
733
|
+
toolsMismatched.push({
|
|
734
|
+
tool: event.tool,
|
|
735
|
+
wanted: event.wanted,
|
|
736
|
+
found: event.found,
|
|
737
|
+
source: event.source,
|
|
738
|
+
detail: event.detail,
|
|
739
|
+
});
|
|
740
|
+
break;
|
|
741
|
+
default:
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// On a foreign BOM no ledger observed the run, so the formulation's
|
|
746
|
+
// platform record is the primary tool evidence and substitutes for the
|
|
747
|
+
// absent `tool.resolved` events. On a same-run scan the probes were
|
|
748
|
+
// already counted once as ledger events, and adding them here would
|
|
749
|
+
// report one probe twice.
|
|
750
|
+
for (const tool of Array.isArray(row.formulationTools)
|
|
751
|
+
? row.formulationTools
|
|
752
|
+
: []) {
|
|
753
|
+
toolsResolved.push(tool);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (row.unsupported) {
|
|
757
|
+
return {
|
|
758
|
+
assessment: {
|
|
759
|
+
ecosystem: row.ecosystem,
|
|
760
|
+
state: "unsupported",
|
|
761
|
+
tier: null,
|
|
762
|
+
ceilingTier: null,
|
|
763
|
+
tierReasons: [
|
|
764
|
+
{
|
|
765
|
+
source: "disk",
|
|
766
|
+
id: "unsupported-ecosystem",
|
|
767
|
+
detail:
|
|
768
|
+
"cdxgen has no project type for this ecosystem, so nothing was parsed even though its manifest is on disk; this is a cdxgen coverage gap, not a project defect.",
|
|
769
|
+
determining: true,
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
componentCount: 0,
|
|
773
|
+
dependencyEdgeCount: 0,
|
|
774
|
+
markersOnDisk: row.markersOnDisk,
|
|
775
|
+
toolsExpected: [],
|
|
776
|
+
toolsResolved: [],
|
|
777
|
+
toolsMissing: [],
|
|
778
|
+
toolsMismatched: [],
|
|
779
|
+
degradations: [],
|
|
780
|
+
findings: [],
|
|
781
|
+
},
|
|
782
|
+
observed,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const hasFindings = row.findings.length > 0;
|
|
787
|
+
// A ledger event corroborates when the BOM shows the damage it explains:
|
|
788
|
+
// a finding fired for this ecosystem, or the ecosystem contributed no
|
|
789
|
+
// dependency graph at all — no components, or components with no edge
|
|
790
|
+
// anchored in them. That second shape is the one an umbrella rule cannot
|
|
791
|
+
// see in a polyglot BOM, where another ecosystem's graph keeps the global
|
|
792
|
+
// rule quiet. Uncorroborated events stay observations — they describe a
|
|
793
|
+
// degraded step that did not cost the output anything measurable.
|
|
794
|
+
const corroborated = hasFindings || row.dependencyEdgeCount === 0;
|
|
795
|
+
|
|
796
|
+
// Gather every candidate reason with the tier it argues for, then pick the
|
|
797
|
+
// worst tier argued: the ladder steps are first-match-wins in the plan's
|
|
798
|
+
// order, which is exactly a tie-break toward the worse tier.
|
|
799
|
+
const candidates = [];
|
|
800
|
+
if (row.markersOnDisk.length > 0 && row.componentCount === 0) {
|
|
801
|
+
candidates.push({
|
|
802
|
+
tier: "absent",
|
|
803
|
+
reason: {
|
|
804
|
+
source: "disk",
|
|
805
|
+
id: "markers",
|
|
806
|
+
detail: `${row.markersOnDisk.length} marker file(s) on disk and no components produced for this ecosystem.`,
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
for (const finding of row.findings) {
|
|
811
|
+
const signal = tierSignal(finding, row.ruleById);
|
|
812
|
+
if (signal && TIER_SEVERITY.has(signal)) {
|
|
813
|
+
candidates.push({
|
|
814
|
+
tier: signal,
|
|
815
|
+
reason: {
|
|
816
|
+
source: reasonSourceOf(finding, row.ruleById),
|
|
817
|
+
id: finding.ruleId,
|
|
818
|
+
detail: finding.message,
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const fallbackWithComponentCost = row.events.find(
|
|
824
|
+
(event) =>
|
|
825
|
+
corroborated &&
|
|
826
|
+
event.kind === LEDGER_EVENT_KINDS.FALLBACK_ENGAGED &&
|
|
827
|
+
(event.impact === LEDGER_EVENT_IMPACTS.VERSIONS ||
|
|
828
|
+
event.impact === LEDGER_EVENT_IMPACTS.COMPONENTS),
|
|
829
|
+
);
|
|
830
|
+
if (fallbackWithComponentCost) {
|
|
831
|
+
candidates.push({
|
|
832
|
+
tier: "heuristic",
|
|
833
|
+
reason: {
|
|
834
|
+
source: "ledger",
|
|
835
|
+
id:
|
|
836
|
+
fallbackWithComponentCost.remediationId ||
|
|
837
|
+
fallbackWithComponentCost.kind,
|
|
838
|
+
detail:
|
|
839
|
+
fallbackWithComponentCost.detail ||
|
|
840
|
+
"A fallback produced components from artifacts instead of a manifest.",
|
|
841
|
+
},
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
const fallbackWithTransitiveCost = row.events.find(
|
|
845
|
+
(event) =>
|
|
846
|
+
corroborated &&
|
|
847
|
+
(event.kind === LEDGER_EVENT_KINDS.FALLBACK_ENGAGED ||
|
|
848
|
+
event.kind === LEDGER_EVENT_KINDS.EVIDENCE_DEGRADED) &&
|
|
849
|
+
event.impact === LEDGER_EVENT_IMPACTS.TRANSITIVE_DEPS,
|
|
850
|
+
);
|
|
851
|
+
if (fallbackWithTransitiveCost) {
|
|
852
|
+
candidates.push({
|
|
853
|
+
tier: "manifest",
|
|
854
|
+
reason: {
|
|
855
|
+
source: "ledger",
|
|
856
|
+
id:
|
|
857
|
+
fallbackWithTransitiveCost.remediationId ||
|
|
858
|
+
fallbackWithTransitiveCost.kind,
|
|
859
|
+
detail:
|
|
860
|
+
fallbackWithTransitiveCost.detail ||
|
|
861
|
+
"Transitive dependency resolution was skipped or failed.",
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const lockfileEvidence = LOCKFILE_EVIDENCE_CHECKS[row.ecosystem];
|
|
866
|
+
if (lockfileEvidence?.(bomJson, facts)) {
|
|
867
|
+
candidates.push({
|
|
868
|
+
tier: "lockfile",
|
|
869
|
+
reason: {
|
|
870
|
+
source: "bom",
|
|
871
|
+
id: "lockfile-evidence",
|
|
872
|
+
detail:
|
|
873
|
+
"Components carry lockfile-only evidence such as integrity hashes or per-package provenance properties.",
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
let tier = "resolved";
|
|
879
|
+
let determiningReasons = [];
|
|
880
|
+
if (candidates.length) {
|
|
881
|
+
const worstTier = candidates.reduce(
|
|
882
|
+
(worst, candidate) =>
|
|
883
|
+
TIER_SEVERITY.get(candidate.tier) > TIER_SEVERITY.get(worst)
|
|
884
|
+
? candidate.tier
|
|
885
|
+
: worst,
|
|
886
|
+
"resolved",
|
|
887
|
+
);
|
|
888
|
+
tier = worstTier;
|
|
889
|
+
determiningReasons = candidates
|
|
890
|
+
.filter((candidate) => candidate.tier === worstTier)
|
|
891
|
+
.map((candidate) => candidate.reason);
|
|
892
|
+
} else {
|
|
893
|
+
determiningReasons = [
|
|
894
|
+
{
|
|
895
|
+
source: "bom",
|
|
896
|
+
id: "no-demoting-signal",
|
|
897
|
+
detail:
|
|
898
|
+
"No fidelity finding fired and no corroborated degradation was recorded for this ecosystem.",
|
|
899
|
+
},
|
|
900
|
+
];
|
|
901
|
+
}
|
|
902
|
+
const tierReasons = [
|
|
903
|
+
...determiningReasons.map((reason) => ({ ...reason, determining: true })),
|
|
904
|
+
...candidates
|
|
905
|
+
.filter((candidate) => candidate.tier !== tier)
|
|
906
|
+
.map((candidate) => ({ ...candidate.reason, determining: false })),
|
|
907
|
+
];
|
|
908
|
+
|
|
909
|
+
const ceilingTier = ECOSYSTEM_CEILINGS[row.ecosystem] || "resolved";
|
|
910
|
+
const state =
|
|
911
|
+
tier === "absent"
|
|
912
|
+
? "absent"
|
|
913
|
+
: tier === ceilingTier
|
|
914
|
+
? "at-ceiling"
|
|
915
|
+
: "graded";
|
|
916
|
+
|
|
917
|
+
// Degradation events become remediation entries only when the BOM and the
|
|
918
|
+
// ledger agree on the damage and the ecosystem has room to improve. On an
|
|
919
|
+
// at-ceiling row the same events are expected noise, so they route to
|
|
920
|
+
// observations instead. The failure facts ride with the degradation record
|
|
921
|
+
// so the remediation entry, not only the observation, can answer "why".
|
|
922
|
+
const degradations = [];
|
|
923
|
+
const seenDegradations = new Map();
|
|
924
|
+
for (const event of row.events) {
|
|
925
|
+
if (!REMEDIABLE_KINDS.has(event.kind)) {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (event.remediationId && corroborated && state !== "at-ceiling") {
|
|
929
|
+
const existing = seenDegradations.get(event.remediationId);
|
|
930
|
+
if (!existing) {
|
|
931
|
+
const degradation = {
|
|
932
|
+
remediationId: event.remediationId,
|
|
933
|
+
impact: event.impact,
|
|
934
|
+
detail: event.detail,
|
|
935
|
+
...failureFactsFromEvent(event),
|
|
936
|
+
};
|
|
937
|
+
seenDegradations.set(event.remediationId, degradation);
|
|
938
|
+
degradations.push(degradation);
|
|
939
|
+
} else if (!existing.outputExcerpt && event.outputExcerpt) {
|
|
940
|
+
// The first event for an id owns the record, but the excerpt is the
|
|
941
|
+
// one fact later duplicates can supply better.
|
|
942
|
+
existing.outputExcerpt = event.outputExcerpt;
|
|
943
|
+
if (existing.exitCode === undefined && event.exitCode !== undefined) {
|
|
944
|
+
existing.exitCode = event.exitCode;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
} else {
|
|
948
|
+
observed.push(observationFromEvent(event));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
assessment: {
|
|
954
|
+
ecosystem: row.ecosystem,
|
|
955
|
+
state,
|
|
956
|
+
tier,
|
|
957
|
+
ceilingTier,
|
|
958
|
+
tierReasons,
|
|
959
|
+
componentCount: row.componentCount,
|
|
960
|
+
dependencyEdgeCount: row.dependencyEdgeCount,
|
|
961
|
+
markersOnDisk: row.markersOnDisk,
|
|
962
|
+
toolsExpected: dedupeToolEntries(toolsExpected),
|
|
963
|
+
toolsResolved: dedupeToolEntries(toolsResolved),
|
|
964
|
+
toolsMissing: dedupeToolEntries(toolsMissing),
|
|
965
|
+
toolsMismatched: dedupeToolEntries(toolsMismatched),
|
|
966
|
+
degradations,
|
|
967
|
+
findings: row.findings.map((finding) => {
|
|
968
|
+
const signal = tierSignal(finding, row.ruleById);
|
|
969
|
+
return signal ? { ...finding, tierSignal: signal } : finding;
|
|
970
|
+
}),
|
|
971
|
+
},
|
|
972
|
+
observed,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Review a completed run and produce a per-ecosystem fidelity assessment.
|
|
978
|
+
*
|
|
979
|
+
* @param {Object} bomJson Generated CycloneDX BOM.
|
|
980
|
+
* @param {Object} options CLI options.
|
|
981
|
+
* @param {Object} [context] Overrides for testing.
|
|
982
|
+
* @param {Object[]} [context.ledgerEvents] Events, defaulting to the sidecar or in-memory ledger.
|
|
983
|
+
* @param {string} [context.ledgerSource] Force the reported ledger source.
|
|
984
|
+
* @param {boolean} [context.ledgerComplete] Force the reported ledger completeness.
|
|
985
|
+
* @param {string} [context.projectPath] Directory scanned, for marker detection.
|
|
986
|
+
* @param {Object} [context.markerHooks] Directory-lister overrides for marker detection tests.
|
|
987
|
+
* @returns {Promise<Object>} The reflection document.
|
|
988
|
+
*/
|
|
989
|
+
export async function reflectOnRun(bomJson, options = {}, context = {}) {
|
|
990
|
+
const { events, source } = resolveLedgerEvents(context);
|
|
991
|
+
let ledgerComplete = context.ledgerComplete !== false;
|
|
992
|
+
const projectPath =
|
|
993
|
+
context.projectPath !== undefined
|
|
994
|
+
? context.projectPath
|
|
995
|
+
: options.filePath || "";
|
|
996
|
+
const projectTypes = requestedProjectTypes(options);
|
|
997
|
+
const facts = collectBomFacts(bomJson);
|
|
998
|
+
const runId = randomUUID();
|
|
999
|
+
// The formulation section is read before the rules fire so the rules can
|
|
1000
|
+
// gate on its origin; on a same-run scan its platform components are the
|
|
1001
|
+
// ledger's own probes reported twice, and only the foreign case may act as
|
|
1002
|
+
// primary evidence.
|
|
1003
|
+
const formulation = readFormulationEvidence(bomJson, {
|
|
1004
|
+
runId,
|
|
1005
|
+
ledgerEventCount: events.length,
|
|
1006
|
+
});
|
|
1007
|
+
const runtime = resolveRuntime(events);
|
|
1008
|
+
const formulationRuntimeMismatch =
|
|
1009
|
+
formulation.origin === "foreign"
|
|
1010
|
+
? findRuntimeToolMismatch(formulation, runtime)
|
|
1011
|
+
: undefined;
|
|
1012
|
+
const rules = await getFidelityRules();
|
|
1013
|
+
const ruleById = new Map(rules.map((rule) => [rule.id, rule]));
|
|
1014
|
+
const findings = await evaluateRules(rules, bomJson, {
|
|
1015
|
+
formulationEvidence: {
|
|
1016
|
+
origin: formulation.origin,
|
|
1017
|
+
runtimeMismatch: formulationRuntimeMismatch,
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
const observations = [];
|
|
1022
|
+
const seenObservations = new Set();
|
|
1023
|
+
const pushObservation = (observation) => {
|
|
1024
|
+
const key = stableStringify(observation);
|
|
1025
|
+
if (!seenObservations.has(key)) {
|
|
1026
|
+
seenObservations.add(key);
|
|
1027
|
+
observations.push(observation);
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
// Events describing the recorder itself are never an ecosystem: the
|
|
1032
|
+
// truncation marker in particular means the ledger is incomplete and must
|
|
1033
|
+
// be reported as such instead of being scored as a finding against
|
|
1034
|
+
// whichever ecosystem happened to hit the cap.
|
|
1035
|
+
const recorderEvents = [];
|
|
1036
|
+
const projectEvents = [];
|
|
1037
|
+
for (const event of events) {
|
|
1038
|
+
if (event.ecosystem === LEDGER_TOOL_ECOSYSTEM) {
|
|
1039
|
+
recorderEvents.push(event);
|
|
1040
|
+
if (event.kind === LEDGER_EVENT_KINDS.EVIDENCE_DEGRADED) {
|
|
1041
|
+
ledgerComplete = false;
|
|
1042
|
+
}
|
|
1043
|
+
} else {
|
|
1044
|
+
projectEvents.push(event);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
for (const event of recorderEvents) {
|
|
1048
|
+
pushObservation(observationFromEvent(event));
|
|
1049
|
+
}
|
|
1050
|
+
if (source === "memory" && events.length > 0) {
|
|
1051
|
+
pushObservation({
|
|
1052
|
+
kind: "ledger.memory-only",
|
|
1053
|
+
ecosystem: LEDGER_TOOL_ECOSYSTEM,
|
|
1054
|
+
detail:
|
|
1055
|
+
"The ledger was read from this thread's memory; set CDXGEN_INTROSPECT_LEDGER to capture worker-thread events in a sidecar.",
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Markers found on disk, bounded to the scanned directory and one level of
|
|
1060
|
+
// subdirectories. Ledger event paths that name a marker file extend the
|
|
1061
|
+
// picture at no extra I/O cost.
|
|
1062
|
+
const { markersByEcosystem } = detectEcosystemMarkers(
|
|
1063
|
+
projectPath,
|
|
1064
|
+
context.markerHooks || {},
|
|
1065
|
+
);
|
|
1066
|
+
for (const event of projectEvents) {
|
|
1067
|
+
const ecosystem = ALIAS_TO_ECOSYSTEM.get(
|
|
1068
|
+
`${event.ecosystem}`.toLowerCase(),
|
|
1069
|
+
);
|
|
1070
|
+
if (ecosystem && event.path && MARKERS_BY_NAME.has(basename(event.path))) {
|
|
1071
|
+
const paths = new Set(markersByEcosystem.get(ecosystem) || []);
|
|
1072
|
+
paths.add(event.path);
|
|
1073
|
+
markersByEcosystem.set(ecosystem, [...paths].sort());
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// The row universe: requested project types, ecosystems present in the
|
|
1078
|
+
// BOM, and ecosystems a finding was attributed to. Ledger events alone
|
|
1079
|
+
// never create a row — probes and declared pins routinely touch
|
|
1080
|
+
// ecosystems the run was not asked to scan.
|
|
1081
|
+
const rowEcosystems = new Set();
|
|
1082
|
+
const unsupportedCandidates = new Set();
|
|
1083
|
+
for (const projectType of projectTypes) {
|
|
1084
|
+
const canonical = canonicalEcosystemFor(projectType);
|
|
1085
|
+
const normalizedType = `${projectType}`.toLowerCase();
|
|
1086
|
+
if (canonical) {
|
|
1087
|
+
rowEcosystems.add(canonical);
|
|
1088
|
+
} else if (!KNOWN_PROJECT_TYPES.has(normalizedType)) {
|
|
1089
|
+
unsupportedCandidates.add(normalizedType);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
for (const ecosystem of facts.componentCountByEcosystem.keys()) {
|
|
1093
|
+
rowEcosystems.add(ecosystem);
|
|
1094
|
+
}
|
|
1095
|
+
// Keyed by the finding itself, not its rule id: one rule fires once per
|
|
1096
|
+
// matched item, and two matches of the same rule can belong to different
|
|
1097
|
+
// ecosystems.
|
|
1098
|
+
const attributedFindings = new Map();
|
|
1099
|
+
for (const finding of findings) {
|
|
1100
|
+
const ecosystem = attributeFinding(finding, ruleById, facts);
|
|
1101
|
+
attributedFindings.set(finding, ecosystem);
|
|
1102
|
+
if (ecosystem) {
|
|
1103
|
+
rowEcosystems.add(ecosystem);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// Markers for ecosystems cdxgen cannot parse are coverage gaps, never
|
|
1107
|
+
// scored rows.
|
|
1108
|
+
for (const ecosystem of markersByEcosystem.keys()) {
|
|
1109
|
+
if (
|
|
1110
|
+
!ALIAS_TO_ECOSYSTEM.has(ecosystem) &&
|
|
1111
|
+
!KNOWN_PROJECT_TYPES.has(ecosystem)
|
|
1112
|
+
) {
|
|
1113
|
+
unsupportedCandidates.add(ecosystem);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
for (const ecosystem of unsupportedCandidates) {
|
|
1117
|
+
rowEcosystems.delete(ecosystem);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const eventsByEcosystem = new Map();
|
|
1121
|
+
for (const event of projectEvents) {
|
|
1122
|
+
const ecosystem = ALIAS_TO_ECOSYSTEM.get(
|
|
1123
|
+
`${event.ecosystem}`.toLowerCase(),
|
|
1124
|
+
);
|
|
1125
|
+
if (!ecosystem || !rowEcosystems.has(ecosystem)) {
|
|
1126
|
+
// Events for ecosystems outside the scan scope are observations when
|
|
1127
|
+
// they report a degradation, and pure context otherwise.
|
|
1128
|
+
if (DEGRADATION_KINDS.has(event.kind)) {
|
|
1129
|
+
pushObservation(observationFromEvent(event));
|
|
1130
|
+
}
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const ecosystemEvents = eventsByEcosystem.get(ecosystem) || [];
|
|
1134
|
+
ecosystemEvents.push(event);
|
|
1135
|
+
eventsByEcosystem.set(ecosystem, ecosystemEvents);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const ecosystems = [];
|
|
1139
|
+
const rowNames = [
|
|
1140
|
+
...new Set([...rowEcosystems, ...unsupportedCandidates]),
|
|
1141
|
+
].sort((a, b) => a.localeCompare(b));
|
|
1142
|
+
// Formulation tool facts join a row only when no ledger observed the run;
|
|
1143
|
+
// on a same-run scan each probe is already recorded as a `tool.resolved`
|
|
1144
|
+
// event and must not be reported twice.
|
|
1145
|
+
const formulationToolsByEcosystem = new Map();
|
|
1146
|
+
if (formulation.origin === "foreign") {
|
|
1147
|
+
for (const tool of formulation.tools) {
|
|
1148
|
+
const ecosystem = TOOL_NAME_ECOSYSTEMS[`${tool?.name}`];
|
|
1149
|
+
if (!ecosystem || !rowEcosystems.has(ecosystem)) {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
const entries = formulationToolsByEcosystem.get(ecosystem) || [];
|
|
1153
|
+
entries.push({
|
|
1154
|
+
tool: tool.name,
|
|
1155
|
+
found: tool.version,
|
|
1156
|
+
source: "formulation",
|
|
1157
|
+
});
|
|
1158
|
+
formulationToolsByEcosystem.set(ecosystem, entries);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
for (const ecosystem of rowNames) {
|
|
1162
|
+
const unsupported = unsupportedCandidates.has(ecosystem);
|
|
1163
|
+
const markers = new Set(markersByEcosystem.get(ecosystem) || []);
|
|
1164
|
+
const rowFindings = findings.filter(
|
|
1165
|
+
(finding) => attributedFindings.get(finding) === ecosystem,
|
|
1166
|
+
);
|
|
1167
|
+
const { assessment, observed } = assessEcosystem(
|
|
1168
|
+
{
|
|
1169
|
+
ecosystem,
|
|
1170
|
+
unsupported,
|
|
1171
|
+
events: eventsByEcosystem.get(ecosystem) || [],
|
|
1172
|
+
findings: rowFindings,
|
|
1173
|
+
ruleById,
|
|
1174
|
+
markersOnDisk: [...markers].sort(),
|
|
1175
|
+
componentCount: unsupported
|
|
1176
|
+
? 0
|
|
1177
|
+
: facts.componentCountByEcosystem.get(ecosystem) || 0,
|
|
1178
|
+
dependencyEdgeCount: unsupported
|
|
1179
|
+
? 0
|
|
1180
|
+
: facts.edgeCountByEcosystem.get(ecosystem) || 0,
|
|
1181
|
+
formulationTools: formulationToolsByEcosystem.get(ecosystem) || [],
|
|
1182
|
+
},
|
|
1183
|
+
bomJson,
|
|
1184
|
+
facts,
|
|
1185
|
+
);
|
|
1186
|
+
ecosystems.push(assessment);
|
|
1187
|
+
for (const observation of observed) {
|
|
1188
|
+
pushObservation(observation);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return {
|
|
1193
|
+
runId,
|
|
1194
|
+
inputsFingerprint: computeInputsFingerprint({
|
|
1195
|
+
projectPath,
|
|
1196
|
+
projectTypes,
|
|
1197
|
+
events: projectEvents,
|
|
1198
|
+
}),
|
|
1199
|
+
generatedAt: new Date().toISOString(),
|
|
1200
|
+
cdxgenVersion: CDXGEN_VERSION,
|
|
1201
|
+
runtime,
|
|
1202
|
+
ledgerSource: context.ledgerSource || source,
|
|
1203
|
+
ledgerComplete,
|
|
1204
|
+
ledgerEventCount: events.length,
|
|
1205
|
+
projectPath,
|
|
1206
|
+
projectTypes: [...projectTypes].sort(),
|
|
1207
|
+
ecosystems,
|
|
1208
|
+
globalFindings: findings.filter(
|
|
1209
|
+
(finding) => attributedFindings.get(finding) === undefined,
|
|
1210
|
+
),
|
|
1211
|
+
observations,
|
|
1212
|
+
formulation,
|
|
1213
|
+
};
|
|
1214
|
+
}
|