echelongraph-mcp 2.3.3 → 2.4.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 +79 -4
- package/dist/http.js +240 -0
- package/dist/httpPolicy.js +244 -0
- package/dist/index.js +179 -50
- package/dist/runtime.js +65 -0
- package/dist/tools/check_affected.js +269 -0
- package/dist/tools/check_sbom.js +329 -0
- package/dist/tools/cve_intel.js +276 -0
- package/dist/tools/epss_history.js +125 -0
- package/dist/tools/get_cwe.js +148 -0
- package/dist/tools/kev_recent.js +190 -0
- package/dist/tools/vendor_advisories.js +316 -0
- package/package.json +4 -2
- package/server.json +8 -2
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// check_affected (#2716): "am I affected by product X at version Y?", answered by the same
|
|
2
|
+
// endpoint as echelongraph.io/am-i-affected, GET /api/v1/public/cves/match (core-backend
|
|
3
|
+
// internal/cve/handler.go MatchByProductPublic). Two lookup paths, chosen by the input:
|
|
4
|
+
//
|
|
5
|
+
// cpe {product, version}: the CPE matcher (matchByProductPublic). CVEs whose NVD CPE
|
|
6
|
+
// product field is this token and whose version range includes this version. It
|
|
7
|
+
// matches the product token across vendors; each match names the vendor NVD asserts
|
|
8
|
+
// (cpe_vendor) and whether that vendor was verified (vendor_unknown).
|
|
9
|
+
// registry {ecosystem, package, version}: the registry matcher (matchByPackage). The OSV
|
|
10
|
+
// advisory records EchelonGraph holds for this package, each decided against this
|
|
11
|
+
// version as affected, not affected, or undetermined.
|
|
12
|
+
//
|
|
13
|
+
// #1983: every typed value travels in a request header (X-EG-Product, X-EG-Version,
|
|
14
|
+
// X-EG-Ecosystem, X-EG-Package; handler.go MatchProductHeader..MatchPackageHeader), percent-
|
|
15
|
+
// encoded as the backend's publicMatchParam decodes it, and never in the URL: Cloud Run's request
|
|
16
|
+
// log and its trace spans record the URL and no request header. The backend answers a
|
|
17
|
+
// header-carried lookup with Cache-Control: private, no-store and varies on the four headers. The
|
|
18
|
+
// endpoint reads the CPE vendor from the query string only (handler.go matchByProductPublic), so
|
|
19
|
+
// this tool takes no vendor: sending one would put a typed value in the URL.
|
|
20
|
+
//
|
|
21
|
+
// The verdict is `assessed`, read BEFORE `count` (the endpoint's own contract): assessed false
|
|
22
|
+
// means the lookup did not evaluate this component, and count 0 then asserts nothing. So the
|
|
23
|
+
// envelope's state is measured only when the answer says assessed true; assessed false, or an
|
|
24
|
+
// answer without assessed, is not_assessed, and the note's first sentence after the head says so,
|
|
25
|
+
// with the reason. A 500/503 (advisory_lookup_failed) is a failure like any other non-2xx.
|
|
26
|
+
//
|
|
27
|
+
// The JSON is relayed verbatim as data (#1874): every match keeps kev_listed, ransomware,
|
|
28
|
+
// epss_score, effective_score, effective_severity and score_assessed as the API sent them.
|
|
29
|
+
import * as z from "zod";
|
|
30
|
+
const TOOL = "check_affected";
|
|
31
|
+
export const MATCH_PATH = "/api/v1/public/cves/match";
|
|
32
|
+
// handler.go, the four request headers the endpoint reads before the query string.
|
|
33
|
+
export const MATCH_HEADERS = { product: "X-EG-Product", version: "X-EG-Version", ecosystem: "X-EG-Ecosystem", package: "X-EG-Package" };
|
|
34
|
+
const CPE_METHOD = "EchelonGraph's CPE matcher over its CVE corpus, the matcher behind echelongraph.io/am-i-affected and EchelonGraph's scanner: CVEs whose NVD CPE match criteria name this product token and whose version range includes this version.";
|
|
35
|
+
const REGISTRY_METHOD = "EchelonGraph's registry matcher, the matcher behind echelongraph.io/am-i-affected for a registry package: the OSV advisory records EchelonGraph holds for this package in this ecosystem, each decided against this version as affected, not affected, or undetermined.";
|
|
36
|
+
const NO_TIME = "measured_at is null: a match answer carries no observation time, and each matched CVE is a record of the CVE feed, polled from its sources on a schedule.";
|
|
37
|
+
const NO_FRESHNESS = "freshness is null: the match answer carries no time at which the feed's pollers last completed.";
|
|
38
|
+
// Why an answer says assessed false (core-backend cve/cpematch.go, cve/pkgmatch.go). One sentence
|
|
39
|
+
// each, worded so that none reads as a clean result.
|
|
40
|
+
const REASONS = {
|
|
41
|
+
product_not_in_cpe_corpus: "no CVE in the CPE corpus has this token as its CPE product, so the component was not located, not cleared; the CPE product token can differ from a package name (spring_boot, not spring-boot), and a registry package can be looked up by ecosystem and package instead.",
|
|
42
|
+
package_not_cpe_nameable: "the CVEs the CPE corpus holds for this token describe it in a way the CPE lookup cannot decide for this asset (an npm package NVD records only for target_sw node.js is the common case), so a registry lookup by ecosystem and package is the one to use.",
|
|
43
|
+
candidate_load_pending: "the product's candidate CVEs were still loading when this request's time ran out (degraded), so nothing was compared; the same lookup can be retried shortly.",
|
|
44
|
+
candidate_window_truncated: "the product has more candidate CVEs than one lookup loads (candidates_capped), so the CVEs below the cut were never examined.",
|
|
45
|
+
package_not_in_advisory_corpus: "EchelonGraph holds no advisory record for this package in this ecosystem, so the package was not located, not cleared; the ecosystem and package name are worth checking.",
|
|
46
|
+
no_decidable_advisory: "EchelonGraph holds advisories for this package, but not one of them could be decided either way at this version (see undetermined).",
|
|
47
|
+
advisory_lookup_failed: "the advisory lookup failed.",
|
|
48
|
+
};
|
|
49
|
+
// The reasons this version describes, and any other string, which the note names as one it does not.
|
|
50
|
+
const REASON = z.union([z.enum(Object.keys(REASONS)), z.string()]).describe("Why assessed is false; empty or absent when it is true.");
|
|
51
|
+
const opt = (t) => t.nullable().optional();
|
|
52
|
+
const Match = z.looseObject({
|
|
53
|
+
cve_id: opt(z.string()),
|
|
54
|
+
description: opt(z.string()),
|
|
55
|
+
severity: opt(z.string()).describe("NVD's own CVSS v3 band, as provenance: it can be NONE for a CVE NVD rated only under CVSS v2. Rank and display on effective_severity."),
|
|
56
|
+
cvss_v3_score: opt(z.number()),
|
|
57
|
+
cvss_v2_score: opt(z.number()),
|
|
58
|
+
cvss_v4_score: opt(z.number()),
|
|
59
|
+
kev_listed: opt(z.boolean()).describe("Listed in CISA-KEV: known to be exploited."),
|
|
60
|
+
ransomware: opt(z.boolean()).describe("CISA-KEV records known use in ransomware campaigns."),
|
|
61
|
+
epss_score: opt(z.number()).describe("EPSS exploitation probability, 0 to 1."),
|
|
62
|
+
echelongraph_score: opt(z.number()).describe("EchelonGraph's 0-10 score: present only when score_assessed is true."),
|
|
63
|
+
echelongraph_severity: opt(z.string()),
|
|
64
|
+
effective_score: opt(z.number()).describe("The best real score held for the CVE (EchelonGraph's, else CVSS), the number the matches are ranked by."),
|
|
65
|
+
effective_severity: opt(z.string()).describe("The band of effective_score; absent or empty when the CVE has no band at all (unrated, not harmless)."),
|
|
66
|
+
score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE. false: NOT YET SCORED, and echelongraph_score is withheld."),
|
|
67
|
+
score_unassessed_reason: opt(z.string()),
|
|
68
|
+
vendor_severity: opt(z.string()),
|
|
69
|
+
matched_criteria: opt(z.string()),
|
|
70
|
+
match_method: opt(z.string()),
|
|
71
|
+
match_confidence: opt(z.number()),
|
|
72
|
+
cpe_vendor: opt(z.string()).describe("The vendor NVD's CPE asserts for this match."),
|
|
73
|
+
vendor_unknown: opt(z.boolean()).describe("true: the vendor was not verified, so the match is real but its attribution to your vendor is not."),
|
|
74
|
+
match_reason: opt(z.string()),
|
|
75
|
+
version_evidence: opt(z.string()),
|
|
76
|
+
});
|
|
77
|
+
const Undetermined = z.looseObject({
|
|
78
|
+
cve_id: opt(z.string()),
|
|
79
|
+
package: opt(z.string()),
|
|
80
|
+
ecosystem: opt(z.string()),
|
|
81
|
+
reason: opt(z.string()),
|
|
82
|
+
detail: opt(z.string()),
|
|
83
|
+
});
|
|
84
|
+
const DATA = z.looseObject({
|
|
85
|
+
product: opt(z.string()),
|
|
86
|
+
version: opt(z.string()),
|
|
87
|
+
vendor: opt(z.string()),
|
|
88
|
+
ecosystem: opt(z.string()),
|
|
89
|
+
ecosystem_recognised: opt(z.boolean()),
|
|
90
|
+
package: opt(z.string()),
|
|
91
|
+
assessed: opt(z.boolean()).describe("Read first. true: the lookup evaluated this component. false: it did not, and count 0 says nothing about whether this version is affected."),
|
|
92
|
+
not_assessed_reason: opt(REASON),
|
|
93
|
+
match_layer: opt(z.string()),
|
|
94
|
+
cve_ids: opt(z.array(z.string())),
|
|
95
|
+
matches: opt(z.array(Match)),
|
|
96
|
+
count: opt(z.number()),
|
|
97
|
+
capped: opt(z.boolean()),
|
|
98
|
+
candidates_capped: opt(z.boolean()),
|
|
99
|
+
candidate_count: opt(z.number()),
|
|
100
|
+
advisories_considered: opt(z.number()),
|
|
101
|
+
excluded_count: opt(z.number()),
|
|
102
|
+
excluded: z.unknown().optional(),
|
|
103
|
+
undetermined_count: opt(z.number()),
|
|
104
|
+
undetermined: opt(z.array(Undetermined)),
|
|
105
|
+
not_affected_count: opt(z.number()),
|
|
106
|
+
degraded: opt(z.boolean()),
|
|
107
|
+
undecidable_excluded_count: opt(z.number()),
|
|
108
|
+
undecided_candidate_count: opt(z.number()),
|
|
109
|
+
product_named_count: opt(z.number()),
|
|
110
|
+
vendor_advisory_count: opt(z.number()),
|
|
111
|
+
});
|
|
112
|
+
const COVERAGE = z.strictObject({
|
|
113
|
+
assessed: z.boolean().nullable().describe("The answer's assessed, first: null when the answer does not say."),
|
|
114
|
+
not_assessed_reason: REASON.nullable(),
|
|
115
|
+
lookup: z.enum(["cpe", "registry"]).describe("Which lookup path this call asked for."),
|
|
116
|
+
match_layer: z.string().nullable(),
|
|
117
|
+
count: z.number().nullable(),
|
|
118
|
+
capped: z.boolean().nullable(),
|
|
119
|
+
candidates_capped: z.boolean().nullable(),
|
|
120
|
+
excluded_count: z.number().nullable(),
|
|
121
|
+
undetermined_count: z.number().nullable(),
|
|
122
|
+
not_affected_count: z.number().nullable(),
|
|
123
|
+
degraded: z.boolean().nullable(),
|
|
124
|
+
});
|
|
125
|
+
// Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
|
|
126
|
+
// this file.
|
|
127
|
+
export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
|
|
128
|
+
export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. Read assessed before count: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there must never be reported as not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON.";
|
|
129
|
+
export const CHECK_AFFECTED_INPUT = z.object({
|
|
130
|
+
product: z.string().optional().describe("CPE path: the NVD CPE product token, such as openssl, nginx or linux_kernel. Leave out for the registry path."),
|
|
131
|
+
ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
|
|
132
|
+
package: z.string().optional().describe("Registry path: the package name in that ecosystem, such as lodash."),
|
|
133
|
+
version: z.string().describe("The version to check, such as 3.0.0."),
|
|
134
|
+
});
|
|
135
|
+
const field = (o, k) => (o !== null && typeof o === "object" ? o[k] : undefined);
|
|
136
|
+
const num = (o, k) => {
|
|
137
|
+
const v = field(o, k);
|
|
138
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
139
|
+
};
|
|
140
|
+
const bool = (o, k) => {
|
|
141
|
+
const v = field(o, k);
|
|
142
|
+
return typeof v === "boolean" ? v : null;
|
|
143
|
+
};
|
|
144
|
+
const str = (o, k) => {
|
|
145
|
+
const v = field(o, k);
|
|
146
|
+
return typeof v === "string" && v ? v : null;
|
|
147
|
+
};
|
|
148
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
149
|
+
// The sentences about the matches themselves: how many are KEV-listed, and which are not scored.
|
|
150
|
+
function matchesNote(matches) {
|
|
151
|
+
const kev = matches.filter((m) => field(m, "kev_listed") === true).length;
|
|
152
|
+
const ransomware = matches.filter((m) => field(m, "ransomware") === true).length;
|
|
153
|
+
const unscored = matches.filter((m) => field(m, "score_assessed") === false).map((m, i) => str(m, "cve_id") ?? `matches[${i}]`);
|
|
154
|
+
const unverified = matches.filter((m) => field(m, "vendor_unknown") === true).length;
|
|
155
|
+
let out = "";
|
|
156
|
+
if (unverified > 0) {
|
|
157
|
+
out += ` ${unverified === matches.length ? "Every match has" : `${plural(unverified, "match has", "matches have")}`} vendor_unknown true: matched on the product name, so the vendor NVD asserts (cpe_vendor) is not verified to be the one you run.`;
|
|
158
|
+
}
|
|
159
|
+
if (kev > 0)
|
|
160
|
+
out += ` ${plural(kev, "of them is", "of them are")} listed in CISA-KEV (kev_listed)${ransomware > 0 ? `, ${ransomware} with known ransomware use` : ""}.`;
|
|
161
|
+
if (unscored.length > 0) {
|
|
162
|
+
const shown = unscored.slice(0, 10).join(", ");
|
|
163
|
+
out += ` NOT YET SCORED (score_assessed: false): ${shown}${unscored.length > 10 ? ` and ${unscored.length - 10} more` : ""}; EchelonGraph has not scored ${unscored.length === 1 ? "it" : "them"}, so an effective_score of zero or an empty effective_severity there means unrated, not harmless.`;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
async function checkAffected(kit, a) {
|
|
168
|
+
const product = (a.product ?? "").trim();
|
|
169
|
+
const ecosystem = (a.ecosystem ?? "").trim();
|
|
170
|
+
const pkg = (a.package ?? "").trim();
|
|
171
|
+
const version = (a.version ?? "").trim();
|
|
172
|
+
if (product && (ecosystem || pkg))
|
|
173
|
+
return kit.badInput(TOOL, "give product (the CPE path) or ecosystem and package (the registry path), not both");
|
|
174
|
+
if (!product && !ecosystem && !pkg)
|
|
175
|
+
return kit.badInput(TOOL, "product, or ecosystem and package, is required");
|
|
176
|
+
if (!product && !ecosystem)
|
|
177
|
+
return kit.badInput(TOOL, "ecosystem is required with package");
|
|
178
|
+
if (!product && !pkg)
|
|
179
|
+
return kit.badInput(TOOL, "package is required with ecosystem");
|
|
180
|
+
if (!version)
|
|
181
|
+
return kit.badInput(TOOL, "version is required");
|
|
182
|
+
const lookup = product ? "cpe" : "registry";
|
|
183
|
+
// #1983: percent-encoded, as the backend's publicMatchParam decodes it (url.PathUnescape), so
|
|
184
|
+
// any value is a legal header and a literal + survives.
|
|
185
|
+
const enc = encodeURIComponent;
|
|
186
|
+
const headers = lookup === "cpe"
|
|
187
|
+
? { [MATCH_HEADERS.product]: enc(product), [MATCH_HEADERS.version]: enc(version) }
|
|
188
|
+
: { [MATCH_HEADERS.ecosystem]: enc(ecosystem), [MATCH_HEADERS.package]: enc(pkg), [MATCH_HEADERS.version]: enc(version) };
|
|
189
|
+
try {
|
|
190
|
+
const r = await kit.api(MATCH_PATH, { headers });
|
|
191
|
+
if (!r.ok)
|
|
192
|
+
return kit.failed(TOOL, r);
|
|
193
|
+
const d = r.data;
|
|
194
|
+
const assessed = bool(d, "assessed");
|
|
195
|
+
const reason = str(d, "not_assessed_reason");
|
|
196
|
+
const matches = Array.isArray(field(d, "matches")) ? field(d, "matches") : [];
|
|
197
|
+
const count = num(d, "count") ?? (Array.isArray(field(d, "matches")) ? matches.length : null);
|
|
198
|
+
const capped = bool(d, "capped");
|
|
199
|
+
const candidatesCapped = bool(d, "candidates_capped");
|
|
200
|
+
const undetermined = num(d, "undetermined_count");
|
|
201
|
+
const notAffected = num(d, "not_affected_count");
|
|
202
|
+
const excluded = num(d, "excluded_count");
|
|
203
|
+
const degraded = bool(d, "degraded");
|
|
204
|
+
const layer = str(d, "match_layer");
|
|
205
|
+
const what = lookup === "cpe" ? `product ${product} at version ${version}` : `${ecosystem} package ${pkg} at version ${version}`;
|
|
206
|
+
// assessed, first.
|
|
207
|
+
let note = kit.okHead(TOOL, r.status);
|
|
208
|
+
if (assessed === true) {
|
|
209
|
+
note += ` (state: measured) assessed: true, so the lookup evaluated ${what}.`;
|
|
210
|
+
}
|
|
211
|
+
else if (assessed === false) {
|
|
212
|
+
const why = reason === null ? "the answer gives no not_assessed_reason." : (REASONS[reason] ?? `the answer gives not_assessed_reason ${reason}, which this version of the server does not describe.`);
|
|
213
|
+
note += ` (state: not_assessed) NOT ASSESSED: assessed is false for ${what}${reason === null ? "" : ` (not_assessed_reason ${reason})`}: ${why} A count of 0 here does not mean this version is not affected: do not report it as unaffected or clean.`;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
note += ` (state: not_assessed) The answer carries no assessed field (an API older than it sends none), so it does not say whether ${what} was evaluated, and a count of 0 here does not mean this version is not affected.`;
|
|
217
|
+
}
|
|
218
|
+
if (count !== null && count > 0) {
|
|
219
|
+
note += ` AFFECTED: ${plural(count, "CVE matches", "CVEs match")} this version${capped === true ? " (capped: true, so the list stopped at its cap and more may match)" : ""}.${matchesNote(matches)}`;
|
|
220
|
+
}
|
|
221
|
+
else if (count === 0 && assessed === true) {
|
|
222
|
+
note += ` 0 CVEs match this version, a measured result (we looked and found nothing that matches), not a lookup failure.`;
|
|
223
|
+
if (lookup === "registry" && notAffected !== null)
|
|
224
|
+
note += ` ${plural(notAffected, "advisory was", "advisories were")} decided not to affect this version (not_affected_count), and only those are cleared.`;
|
|
225
|
+
}
|
|
226
|
+
if (undetermined !== null && undetermined > 0) {
|
|
227
|
+
note += ` UNDETERMINED: ${plural(undetermined, "advisory names", "advisories name")} this package with a version range that could not be decided at this version (undetermined_count, listed in undetermined): report ${undetermined === 1 ? "it" : "them"} as undetermined, never as safe.`;
|
|
228
|
+
}
|
|
229
|
+
if (candidatesCapped === true && reason !== "candidate_window_truncated")
|
|
230
|
+
note += " candidates_capped is true: not every candidate CVE was loaded, so more CVEs may match than the answer lists.";
|
|
231
|
+
if (excluded !== null && excluded > 0)
|
|
232
|
+
note += ` ${plural(excluded, "candidate CVE was", "candidate CVEs were")} suppressed by the vendor or platform gate (excluded_count, listed in excluded).`;
|
|
233
|
+
if (degraded === true)
|
|
234
|
+
note += " degraded is true: the lookup ran out of time before it finished, so this answer is incomplete.";
|
|
235
|
+
return kit.succeeded(d, note, {
|
|
236
|
+
state: assessed === true ? "measured" : "not_assessed",
|
|
237
|
+
measured_at: null,
|
|
238
|
+
method: (layer ?? lookup) === "registry" ? REGISTRY_METHOD : CPE_METHOD,
|
|
239
|
+
coverage: {
|
|
240
|
+
assessed,
|
|
241
|
+
not_assessed_reason: reason,
|
|
242
|
+
lookup,
|
|
243
|
+
match_layer: layer,
|
|
244
|
+
count,
|
|
245
|
+
capped,
|
|
246
|
+
candidates_capped: candidatesCapped,
|
|
247
|
+
excluded_count: excluded,
|
|
248
|
+
undetermined_count: undetermined,
|
|
249
|
+
not_affected_count: notAffected,
|
|
250
|
+
degraded,
|
|
251
|
+
},
|
|
252
|
+
freshness: null,
|
|
253
|
+
notes: [NO_TIME, NO_FRESHNESS],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
return kit.crashed(TOOL, e);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export function registerCheckAffected(server, kit) {
|
|
261
|
+
const output = kit.envelopeSchema({ data: DATA, coverage: COVERAGE, freshness: null });
|
|
262
|
+
server.registerTool(TOOL, {
|
|
263
|
+
title: CHECK_AFFECTED_TITLE,
|
|
264
|
+
description: CHECK_AFFECTED_DESCRIPTION,
|
|
265
|
+
inputSchema: CHECK_AFFECTED_INPUT,
|
|
266
|
+
outputSchema: output,
|
|
267
|
+
annotations: kit.annotations,
|
|
268
|
+
}, async (a) => kit.checked(TOOL, output, await checkAffected(kit, a)));
|
|
269
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export const CHECK_SBOM = "check_sbom";
|
|
3
|
+
export const BATCH_PATH = "/api/v1/public/cves/match/batch";
|
|
4
|
+
// The backend's cap (cveBatchMaxComponents). A document with more is refused here, before any
|
|
5
|
+
// request, and never truncated: a dropped component would read as a clean one.
|
|
6
|
+
export const MAX_COMPONENTS = 200;
|
|
7
|
+
// The backend's per-purl cap (cveBatchMaxPurlLen).
|
|
8
|
+
const MAX_PURL_LEN = 512;
|
|
9
|
+
// The document's size cap, as JSON text. OWASP Juice Shop 11.1.2's full CycloneDX SBOM (840
|
|
10
|
+
// components) is 0.74 MB; this leaves room for a much larger one while bounding what this process
|
|
11
|
+
// parses.
|
|
12
|
+
export const MAX_SBOM_CHARS = 5_000_000;
|
|
13
|
+
// How many affected components the note names, and how many CVE ids per component.
|
|
14
|
+
const NOTE_AFFECTED_MAX = 25;
|
|
15
|
+
const NOTE_CVES_MAX = 5;
|
|
16
|
+
// CycloneDX nests components; this bounds the walk.
|
|
17
|
+
const MAX_DEPTH = 32;
|
|
18
|
+
const METHOD = "EchelonGraph's registry advisory matcher (POST /api/v1/public/cves/match/batch): each component's purl is mapped to its OSV ecosystem, package name and version, and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records, the same matcher GET /api/v1/public/cves/match uses when given an ecosystem. No CPE matching, no score, no ranking.";
|
|
19
|
+
// Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
|
|
20
|
+
const DISTRO_NOTE = "A deb, apk or rpm purl without a distro qualifier naming its release (for example ?distro=debian-12 or ?distro=alpine-3.20) is not assessed (distro_release_unknown): the advisory corpus is keyed per distro release, and EchelonGraph does not guess one.";
|
|
21
|
+
const CLEAN_NOTE = "Only not_affected is a clean verdict, and only for the advisories decided at that version: an undetermined or not_assessed component is not a finding of no vulnerability, and is reported as unchecked, never as clean.";
|
|
22
|
+
const MEASURED_AT_NOTE = "measured_at is null: the corpus rows are read through a cache whose maximum age the answer states (summary.corpus_cache_max_age_ms), so the answer gives no single time at which they were read.";
|
|
23
|
+
const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
|
|
24
|
+
export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
|
|
25
|
+
export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 200) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, up to 5,000,000 characters). The purls are read from the document on this machine and only they are sent, in the body of one POST, never in a URL; the document itself is not sent. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts. not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. A list or document with more than 200 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held and what was sent), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
|
|
26
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
27
|
+
function dedupe(all) {
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const purls = [];
|
|
30
|
+
for (const p of all) {
|
|
31
|
+
if (seen.has(p))
|
|
32
|
+
continue;
|
|
33
|
+
seen.add(p);
|
|
34
|
+
purls.push(p);
|
|
35
|
+
}
|
|
36
|
+
return { purls, removed: all.length - purls.length };
|
|
37
|
+
}
|
|
38
|
+
// The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
|
|
39
|
+
// metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
|
|
40
|
+
function fromCycloneDX(doc) {
|
|
41
|
+
if (doc.components !== undefined && !Array.isArray(doc.components))
|
|
42
|
+
return "its components is not an array";
|
|
43
|
+
let total = 0;
|
|
44
|
+
let without = 0;
|
|
45
|
+
const found = [];
|
|
46
|
+
const walk = (cs, depth) => {
|
|
47
|
+
if (!Array.isArray(cs))
|
|
48
|
+
return null;
|
|
49
|
+
if (depth > MAX_DEPTH)
|
|
50
|
+
return `its components nest deeper than ${MAX_DEPTH} levels`;
|
|
51
|
+
for (const c of cs) {
|
|
52
|
+
if (!isObj(c))
|
|
53
|
+
continue;
|
|
54
|
+
total++;
|
|
55
|
+
const p = typeof c.purl === "string" ? c.purl.trim() : "";
|
|
56
|
+
if (p)
|
|
57
|
+
found.push(p);
|
|
58
|
+
else
|
|
59
|
+
without++;
|
|
60
|
+
const err = walk(c.components, depth + 1);
|
|
61
|
+
if (err)
|
|
62
|
+
return err;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
const err = walk(doc.components, 0);
|
|
67
|
+
if (err)
|
|
68
|
+
return err;
|
|
69
|
+
const { purls, removed } = dedupe(found);
|
|
70
|
+
return { input: "cyclonedx", components_in_document: total, with_purl: found.length, without_purl: without, duplicates_removed: removed, purls };
|
|
71
|
+
}
|
|
72
|
+
// The purls of an SPDX JSON document: each package's externalRefs entry of referenceType purl
|
|
73
|
+
// (referenceCategory PACKAGE-MANAGER, spelled PACKAGE_MANAGER by some tools; the type decides).
|
|
74
|
+
function fromSPDX(doc) {
|
|
75
|
+
if (doc.packages !== undefined && !Array.isArray(doc.packages))
|
|
76
|
+
return "its packages is not an array";
|
|
77
|
+
const pkgs = Array.isArray(doc.packages) ? doc.packages : [];
|
|
78
|
+
let total = 0;
|
|
79
|
+
let without = 0;
|
|
80
|
+
const found = [];
|
|
81
|
+
for (const p of pkgs) {
|
|
82
|
+
if (!isObj(p))
|
|
83
|
+
continue;
|
|
84
|
+
total++;
|
|
85
|
+
const refs = Array.isArray(p.externalRefs) ? p.externalRefs : [];
|
|
86
|
+
const ref = refs.find((r) => isObj(r) && typeof r.referenceType === "string" && r.referenceType.toLowerCase() === "purl" && typeof r.referenceLocator === "string" && r.referenceLocator.trim());
|
|
87
|
+
if (ref && isObj(ref))
|
|
88
|
+
found.push(String(ref.referenceLocator).trim());
|
|
89
|
+
else
|
|
90
|
+
without++;
|
|
91
|
+
}
|
|
92
|
+
const { purls, removed } = dedupe(found);
|
|
93
|
+
return { input: "spdx", components_in_document: total, with_purl: found.length, without_purl: without, duplicates_removed: removed, purls };
|
|
94
|
+
}
|
|
95
|
+
// Reads the tool's arguments into the purls to send, or says why it cannot.
|
|
96
|
+
export function extract(a) {
|
|
97
|
+
const hasPurls = a.purls !== undefined;
|
|
98
|
+
const hasSbom = a.sbom !== undefined;
|
|
99
|
+
if (hasPurls === hasSbom)
|
|
100
|
+
return "pass exactly one of purls (a list of package URLs) or sbom (a CycloneDX JSON or SPDX JSON document)";
|
|
101
|
+
if (hasPurls) {
|
|
102
|
+
if (!Array.isArray(a.purls) || a.purls.some((p) => typeof p !== "string"))
|
|
103
|
+
return "purls must be a list of strings";
|
|
104
|
+
const trimmed = a.purls.map((p) => p.trim()).filter(Boolean);
|
|
105
|
+
const { purls, removed } = dedupe(trimmed);
|
|
106
|
+
return { input: "purls", components_in_document: null, with_purl: trimmed.length, without_purl: a.purls.length - trimmed.length, duplicates_removed: removed, purls };
|
|
107
|
+
}
|
|
108
|
+
let doc = a.sbom;
|
|
109
|
+
if (typeof doc === "string") {
|
|
110
|
+
if (doc.length > MAX_SBOM_CHARS)
|
|
111
|
+
return `sbom is ${doc.length} characters; at most ${MAX_SBOM_CHARS} are read`;
|
|
112
|
+
try {
|
|
113
|
+
doc = JSON.parse(doc);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return "sbom is not JSON: pass a CycloneDX JSON or SPDX JSON document (XML and SPDX tag-value are not read)";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
let size = 0;
|
|
121
|
+
try {
|
|
122
|
+
size = JSON.stringify(doc)?.length ?? 0;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return "sbom could not be serialised as JSON";
|
|
126
|
+
}
|
|
127
|
+
if (size > MAX_SBOM_CHARS)
|
|
128
|
+
return `sbom is ${size} characters as JSON; at most ${MAX_SBOM_CHARS} are read`;
|
|
129
|
+
}
|
|
130
|
+
if (!isObj(doc))
|
|
131
|
+
return "sbom must be a JSON object: a CycloneDX JSON or SPDX JSON document";
|
|
132
|
+
if (typeof doc.bomFormat === "string" && doc.bomFormat.toLowerCase() === "cyclonedx") {
|
|
133
|
+
const r = fromCycloneDX(doc);
|
|
134
|
+
return typeof r === "string" ? `the CycloneDX document cannot be read: ${r}` : r;
|
|
135
|
+
}
|
|
136
|
+
if (typeof doc.spdxVersion === "string" && doc.spdxVersion.toUpperCase().startsWith("SPDX-")) {
|
|
137
|
+
const r = fromSPDX(doc);
|
|
138
|
+
return typeof r === "string" ? `the SPDX document cannot be read: ${r}` : r;
|
|
139
|
+
}
|
|
140
|
+
return 'sbom is neither a CycloneDX JSON document (bomFormat "CycloneDX") nor an SPDX JSON document (spdxVersion "SPDX-…")';
|
|
141
|
+
}
|
|
142
|
+
// ── The answer ──
|
|
143
|
+
const field = (o, k) => (isObj(o) ? o[k] : undefined);
|
|
144
|
+
const count = (o, k) => {
|
|
145
|
+
const v = field(o, k);
|
|
146
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
147
|
+
};
|
|
148
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
149
|
+
function noteFor(head, x, d) {
|
|
150
|
+
const s = field(d, "summary");
|
|
151
|
+
const affected = count(s, "affected") ?? 0;
|
|
152
|
+
const notAffected = count(s, "not_affected") ?? 0;
|
|
153
|
+
const undetermined = count(s, "undetermined") ?? 0;
|
|
154
|
+
const notAssessed = count(s, "not_assessed") ?? 0;
|
|
155
|
+
const components = count(s, "components") ?? x.purls.length;
|
|
156
|
+
const out = [head];
|
|
157
|
+
if (x.input === "purls") {
|
|
158
|
+
out.push(`Sent ${plural(x.purls.length, "purl", "purls")}${x.duplicates_removed ? ` (${plural(x.duplicates_removed, "duplicate was", "duplicates were")} sent once)` : ""}.`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const kind = x.input === "cyclonedx" ? "CycloneDX" : "SPDX";
|
|
162
|
+
const unit = x.input === "cyclonedx" ? "component" : "package";
|
|
163
|
+
out.push(`Read ${plural(x.components_in_document ?? 0, unit, `${unit}s`)} from the ${kind} document: ${x.with_purl} with a purl${x.without_purl ? `, and ${x.without_purl} without one, which ${x.without_purl === 1 ? "was" : "were"} not checked and ${x.without_purl === 1 ? "is" : "are"} not clean` : ""}${x.duplicates_removed ? `; ${plural(x.duplicates_removed, "duplicate purl was", "duplicate purls were")} sent once` : ""}; sent ${x.purls.length}.`);
|
|
164
|
+
}
|
|
165
|
+
out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
|
|
166
|
+
const by = field(s, "not_assessed_by_reason");
|
|
167
|
+
if (isObj(by)) {
|
|
168
|
+
const parts = Object.entries(by)
|
|
169
|
+
.filter(([, n]) => typeof n === "number" && n > 0)
|
|
170
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
171
|
+
.map(([r, n]) => `${r} ${n}`);
|
|
172
|
+
if (parts.length)
|
|
173
|
+
out.push(`Not assessed, by not_assessed_reason: ${parts.join(", ")}.`);
|
|
174
|
+
const tb = count(by, "time_budget") ?? 0;
|
|
175
|
+
if (tb > 0) {
|
|
176
|
+
out.push(`The batch's time budget ran out before ${plural(tb, "component was", "components were")} looked up: ${tb === 1 ? "it is" : "they are"} not assessed, not clean; check ${tb === 1 ? "it" : "them"} again.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const rows = field(d, "results");
|
|
180
|
+
if (Array.isArray(rows)) {
|
|
181
|
+
const hits = rows.filter((r) => field(r, "verdict") === "affected");
|
|
182
|
+
if (hits.length) {
|
|
183
|
+
const named = hits.slice(0, NOTE_AFFECTED_MAX).map((r) => {
|
|
184
|
+
const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
|
|
185
|
+
const ids = field(r, "cve_ids");
|
|
186
|
+
const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
|
|
187
|
+
const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
|
|
188
|
+
return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
|
|
189
|
+
});
|
|
190
|
+
out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { note: out.join(" "), assessed: affected + notAffected + undetermined };
|
|
194
|
+
}
|
|
195
|
+
export async function checkSbom(deps, a) {
|
|
196
|
+
const tool = CHECK_SBOM;
|
|
197
|
+
try {
|
|
198
|
+
const x = extract(a);
|
|
199
|
+
if (typeof x === "string")
|
|
200
|
+
return deps.badInput(tool, x);
|
|
201
|
+
if (x.purls.length === 0) {
|
|
202
|
+
return deps.badInput(tool, x.input === "purls" ? "purls holds no package URL" : `the document holds ${x.components_in_document ?? 0} components and none carries a purl, so there is nothing to check`);
|
|
203
|
+
}
|
|
204
|
+
if (x.purls.length > MAX_COMPONENTS) {
|
|
205
|
+
return deps.badInput(tool, `${x.purls.length} distinct purls; at most ${MAX_COMPONENTS} are checked per call, and none is dropped silently: split the list into calls of ${MAX_COMPONENTS} or fewer`);
|
|
206
|
+
}
|
|
207
|
+
const long = x.purls.findIndex((p) => p.length > MAX_PURL_LEN);
|
|
208
|
+
if (long >= 0)
|
|
209
|
+
return deps.badInput(tool, `purl ${long + 1} is longer than ${MAX_PURL_LEN} characters`);
|
|
210
|
+
const r = await deps.api(BATCH_PATH, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "Content-Type": "application/json" },
|
|
213
|
+
body: JSON.stringify({ components: x.purls.map((purl) => ({ purl })) }),
|
|
214
|
+
});
|
|
215
|
+
if (!r.ok)
|
|
216
|
+
return deps.failed(tool, r);
|
|
217
|
+
const { note, assessed } = noteFor(deps.okHead(tool, r.status), x, r.data);
|
|
218
|
+
const s = field(r.data, "summary");
|
|
219
|
+
return deps.succeeded(r.data, note, {
|
|
220
|
+
state: assessed > 0 ? "measured" : "not_assessed",
|
|
221
|
+
measured_at: null,
|
|
222
|
+
method: METHOD,
|
|
223
|
+
coverage: {
|
|
224
|
+
input: x.input,
|
|
225
|
+
components_in_document: x.components_in_document,
|
|
226
|
+
with_purl: x.with_purl,
|
|
227
|
+
without_purl: x.without_purl,
|
|
228
|
+
duplicates_removed: x.duplicates_removed,
|
|
229
|
+
sent: x.purls.length,
|
|
230
|
+
not_assessed: count(s, "not_assessed") ?? null,
|
|
231
|
+
partial: typeof field(s, "partial") === "boolean" ? field(s, "partial") : null,
|
|
232
|
+
},
|
|
233
|
+
freshness: null,
|
|
234
|
+
notes: [DISTRO_NOTE, CLEAN_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
return deps.crashed(tool, e);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// ── Schemas ──
|
|
242
|
+
const opt = (t) => t.nullable().optional();
|
|
243
|
+
const Row = z.looseObject({
|
|
244
|
+
index: opt(z.number()),
|
|
245
|
+
input_kind: opt(z.string()),
|
|
246
|
+
purl: opt(z.string()),
|
|
247
|
+
verdict: opt(z.string()).describe("affected (count > 0); not_affected (assessed, no match, nothing undetermined: the only clean verdict); undetermined (advisories name the package, at least one could not be decided, none matched: not clean); not_assessed (no verdict: not clean)."),
|
|
248
|
+
ecosystem: opt(z.string()),
|
|
249
|
+
package: opt(z.string()),
|
|
250
|
+
version: opt(z.string()),
|
|
251
|
+
assessed: opt(z.boolean()).describe("Whether the matcher produced a verdict for this component. false is never clean."),
|
|
252
|
+
not_assessed_reason: opt(z.string()).describe("Why assessed is false: package_not_in_advisory_corpus, no_decidable_advisory, advisory_lookup_failed, candidate_window_truncated, time_budget, distro_release_unknown, purl_type_unsupported, version_missing or invalid_component."),
|
|
253
|
+
code: opt(z.string()),
|
|
254
|
+
error: opt(z.string()),
|
|
255
|
+
cve_ids: opt(z.array(z.string())),
|
|
256
|
+
matches: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
257
|
+
count: opt(z.number()),
|
|
258
|
+
advisories_considered: opt(z.number()),
|
|
259
|
+
not_affected_count: opt(z.number()),
|
|
260
|
+
undetermined_count: opt(z.number()),
|
|
261
|
+
undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
262
|
+
capped: opt(z.boolean()),
|
|
263
|
+
candidates_capped: opt(z.boolean()),
|
|
264
|
+
});
|
|
265
|
+
export function checkSbomOutput(envelopeSchema) {
|
|
266
|
+
return envelopeSchema({
|
|
267
|
+
data: z.looseObject({
|
|
268
|
+
match_layer: opt(z.string()),
|
|
269
|
+
components: opt(z.number()),
|
|
270
|
+
summary: z
|
|
271
|
+
.looseObject({
|
|
272
|
+
components: opt(z.number()),
|
|
273
|
+
affected: opt(z.number()),
|
|
274
|
+
not_affected: opt(z.number()).describe("Components with a decided, clean verdict."),
|
|
275
|
+
undetermined: opt(z.number()).describe("Components whose advisories could not all be decided and none matched: not clean."),
|
|
276
|
+
not_assessed: opt(z.number()).describe("Components with no verdict: not clean."),
|
|
277
|
+
not_assessed_by_reason: opt(z.looseObject({
|
|
278
|
+
package_not_in_advisory_corpus: z.number().optional(),
|
|
279
|
+
no_decidable_advisory: z.number().optional(),
|
|
280
|
+
advisory_lookup_failed: z.number().optional(),
|
|
281
|
+
candidate_window_truncated: z.number().optional(),
|
|
282
|
+
time_budget: z.number().optional().describe("Components the batch's time budget ran out before: not clean; check them again."),
|
|
283
|
+
distro_release_unknown: z.number().optional().describe("deb, apk or rpm purls without a distro qualifier naming the release, which EchelonGraph does not guess."),
|
|
284
|
+
purl_type_unsupported: z.number().optional(),
|
|
285
|
+
version_missing: z.number().optional(),
|
|
286
|
+
invalid_component: z.number().optional(),
|
|
287
|
+
})).describe("The not_assessed components, counted by not_assessed_reason."),
|
|
288
|
+
lookups: opt(z.number()),
|
|
289
|
+
partial: opt(z.boolean()).describe("true when the time budget ran out before every component was looked up."),
|
|
290
|
+
time_budget_ms: opt(z.number()),
|
|
291
|
+
elapsed_ms: opt(z.number()),
|
|
292
|
+
corpus_cache_max_age_ms: opt(z.number()),
|
|
293
|
+
})
|
|
294
|
+
.optional(),
|
|
295
|
+
answered_at: opt(z.string()),
|
|
296
|
+
results: z.array(Row).optional(),
|
|
297
|
+
}),
|
|
298
|
+
coverage: z.strictObject({
|
|
299
|
+
input: z.enum(["purls", "cyclonedx", "spdx"]).describe("What was passed: a purl list, a CycloneDX JSON document or an SPDX JSON document."),
|
|
300
|
+
components_in_document: z.number().int().nullable().describe("Components (CycloneDX) or packages (SPDX) in the document; null for a purl list."),
|
|
301
|
+
with_purl: z.number().int().describe("Of those, the ones carrying a purl."),
|
|
302
|
+
without_purl: z.number().int().describe("The ones without a purl: not checked, and not clean."),
|
|
303
|
+
duplicates_removed: z.number().int().describe("Purls that appeared more than once and were sent once."),
|
|
304
|
+
sent: z.number().int().describe("Distinct purls sent and checked."),
|
|
305
|
+
not_assessed: z.number().int().nullable().describe("Of those sent, the components with no verdict, as the answer counts them."),
|
|
306
|
+
partial: z.boolean().nullable().describe("The answer's summary.partial: true when the time budget ran out first."),
|
|
307
|
+
}),
|
|
308
|
+
freshness: null,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// A literal server.registerTool(name, { title, description, … }) call with constant strings:
|
|
312
|
+
// marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
|
|
313
|
+
// function) and holds the /pulse/mcp row to this title and description.
|
|
314
|
+
export function registerCheckSbom(server, deps) {
|
|
315
|
+
const output = checkSbomOutput(deps.envelopeSchema);
|
|
316
|
+
server.registerTool(CHECK_SBOM, {
|
|
317
|
+
title: CHECK_SBOM_TITLE,
|
|
318
|
+
description: CHECK_SBOM_DESCRIPTION,
|
|
319
|
+
inputSchema: z.object({
|
|
320
|
+
purls: z.array(z.string()).optional().describe("package URLs to check, e.g. pkg:npm/lodash@4.17.20 or pkg:deb/debian/openssl@3.0.11-1~deb12u1?distro=debian-12 (up to 200)"),
|
|
321
|
+
sbom: z
|
|
322
|
+
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
323
|
+
.optional()
|
|
324
|
+
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read here and only they are sent"),
|
|
325
|
+
}),
|
|
326
|
+
outputSchema: output,
|
|
327
|
+
annotations: deps.annotations,
|
|
328
|
+
}, async (a) => deps.checked(CHECK_SBOM, output, await checkSbom(deps, a)));
|
|
329
|
+
}
|