echelongraph-mcp 2.6.7 → 2.7.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/CHANGELOG.md +147 -0
- package/README.md +64 -30
- package/dist/http.js +2 -2
- package/dist/httpPolicy.js +13 -9
- package/dist/index.js +148 -57
- package/dist/prompts.js +102 -9
- package/dist/requiredText.js +118 -0
- package/dist/resources.js +2 -0
- package/dist/tools/check_affected.js +41 -18
- package/dist/tools/check_sbom.js +37 -374
- package/dist/tools/cve_intel.js +1 -1
- package/dist/tools/cve_remediation.js +257 -0
- package/dist/tools/epss_history.js +100 -6
- package/dist/tools/kev_recent.js +4 -4
- package/dist/tools/manifests/common.js +48 -0
- package/dist/tools/manifests/gomod.js +168 -0
- package/dist/tools/manifests/index.js +80 -0
- package/dist/tools/manifests/lockfiles.js +176 -0
- package/dist/tools/manifests/npmlock.js +107 -0
- package/dist/tools/manifests/requirements.js +125 -0
- package/dist/tools/manifests/toml.js +299 -0
- package/dist/tools/match_batch.js +432 -0
- package/dist/tools/scan_manifest.js +257 -0
- package/dist/tools/vendor_advisories.js +128 -20
- package/package.json +3 -1
- package/server.json +2 -2
package/dist/tools/check_sbom.js
CHANGED
|
@@ -1,45 +1,14 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
|
+
import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, isObj, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
|
|
4
|
+
// #2835: the batch loop, its constants and the row schema moved to match_batch.ts, which
|
|
5
|
+
// scan_manifest shares; re-exported here, where the tests and prompts.ts import them.
|
|
6
|
+
export { API_COMPONENTS_PER_MINUTE, BATCH_PATH, MAX_COMPONENTS, MAX_PURLS, TIME_BUDGET_MS, mergeAnswers } from "./match_batch.js";
|
|
3
7
|
export const CHECK_SBOM = "check_sbom";
|
|
4
|
-
export const BATCH_PATH = "/api/v1/public/cves/match/batch";
|
|
5
|
-
// The backend's cap per request (cveBatchMaxComponents): the batch size. A longer list is sent
|
|
6
|
-
// as consecutive batches of at most this many, one after another (#2734).
|
|
7
|
-
export const MAX_COMPONENTS = 200;
|
|
8
|
-
// The backend charges the batch route per component: 1,200 components a minute per caller
|
|
9
|
-
// (core-backend internal/waf/weighted.go, CVEMatchBatchComponentsPerWindow). Over it, it answers
|
|
10
|
-
// 429 with Retry-After and looks nothing up.
|
|
11
|
-
export const API_COMPONENTS_PER_MINUTE = 1200;
|
|
12
|
-
// The most distinct purls one call checks: two minutes of that budget. From a fresh budget, 2,000
|
|
13
|
-
// purls are 10 batches: six go at once, the API then asks for a wait of at most a minute, and the
|
|
14
|
-
// other four follow, and waiting out that minute would pass TIME_BUDGET_MS, so the call answers partial:
|
|
15
|
-
// the first 1,200 checked, the rest named for a second call. A list with
|
|
16
|
-
// more is refused before any request, never truncated: a dropped component would read as clean.
|
|
17
|
-
export const MAX_PURLS = 2000;
|
|
18
|
-
// How long one call may spend, waits for Retry-After included. When the next wait would pass it,
|
|
19
|
-
// the call stops and answers what it has, with the purls not sent counted in coverage.not_sent
|
|
20
|
-
// and listed in data.not_sent_purls, never dropped silently.
|
|
21
|
-
// 50 s, not more: MCP clients time a tools/call out (the TypeScript SDK's default request timeout is
|
|
22
|
-
// 60 s), and a call that outlives its client returns nothing at all — not even the partial answer
|
|
23
|
-
// below. From a fresh budget, 1,200 purls (6 batches) go at once; a larger list answers partial
|
|
24
|
-
// with the rest in data.not_sent_purls, for a call a minute later.
|
|
25
|
-
// The budget bounds the whole call, not only when a batch may start (#2756): each batch's request
|
|
26
|
-
// may take at most what is left of it (api()'s init.timeoutMs), so a batch started at 49.9 s is cut
|
|
27
|
-
// off at 50 s and its purls answered as not sent (time_budget), instead of running on for the
|
|
28
|
-
// request timeout (15 s) past the client's and the hosted endpoint's 60 s.
|
|
29
|
-
export const TIME_BUDGET_MS = 50_000;
|
|
30
|
-
// Retry-After 0 is waited this long; a 429 without Retry-After is not retried. At most MAX_WAITS
|
|
31
|
-
// waits per call.
|
|
32
|
-
const MIN_WAIT_MS = 1000;
|
|
33
|
-
const MAX_WAITS = 10;
|
|
34
|
-
// The backend's per-purl cap (cveBatchMaxPurlLen).
|
|
35
|
-
const MAX_PURL_LEN = 512;
|
|
36
8
|
// The document's size cap, as JSON text. OWASP Juice Shop 11.1.2's full CycloneDX SBOM (840
|
|
37
9
|
// components) is 0.74 MB; this leaves room for a much larger one while bounding what this process
|
|
38
10
|
// parses.
|
|
39
11
|
export const MAX_SBOM_CHARS = 5_000_000;
|
|
40
|
-
// How many affected components the note names, and how many CVE ids per component.
|
|
41
|
-
const NOTE_AFFECTED_MAX = 25;
|
|
42
|
-
const NOTE_CVES_MAX = 5;
|
|
43
12
|
// CycloneDX nests components; this bounds the walk.
|
|
44
13
|
const MAX_DEPTH = 32;
|
|
45
14
|
// The order in which extract() reads and counts the distinct purls (#2799 review): purls as listed;
|
|
@@ -48,21 +17,23 @@ const MAX_DEPTH = 32;
|
|
|
48
17
|
// place (dedupe). The note says it beside the position from which the purls not sent run, so a
|
|
49
18
|
// second call can rebuild them from its own input whether or not the text cuts not_sent_purls.
|
|
50
19
|
const READ_ORDER = "counted in the order this tool read them (the order of purls, or of the document's components or packages, each component's nested components right after it and before its next sibling, each purl at its first place)";
|
|
51
|
-
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.";
|
|
52
|
-
// Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
|
|
53
|
-
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.";
|
|
54
|
-
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.";
|
|
55
|
-
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.";
|
|
56
|
-
const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
|
|
57
20
|
export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
|
|
58
|
-
|
|
21
|
+
// #2846: the routing sentence for the pods-and-images question, inside the description's first
|
|
22
|
+
// 2,048 characters (test/prompts-resources.test.mjs holds it there, with a control). The workload
|
|
23
|
+
// itself never reaches this tool: the workload_triage prompt has the agent build each image's SBOM
|
|
24
|
+
// with its own tools and pass the purls.
|
|
25
|
+
export const CHECK_SBOM_ROUTING = "For container images and Kubernetes pods, the input is an SBOM of each image, or its purls.";
|
|
26
|
+
export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. " +
|
|
27
|
+
CHECK_SBOM_ROUTING +
|
|
28
|
+
" Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX JSON or SPDX JSON document, up to 5,000,000 characters). The purls are read from the document by this MCP server and only they are sent to the API, in POST bodies of at most 200 purls each, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier is matched as its source package); each match carries fixed_in, its advisory interval's fixed bound, or null with fixed_in_reason. data.summary counts the verdicts. not_affected is the only clean verdict. not_assessed is no verdict, as for a deb, apk or rpm purl without a distro qualifier naming its release (EchelonGraph does not guess one); neither it nor undetermined is clean, and the note counts both. The API allows 1,200 components a minute per caller; on a 429 the tool waits out Retry-After, up to 50 seconds a call, then answers what it has: data.not_sent_purls lists the unsent purls, unchecked and not clean. A list or document with more than 2,000 distinct purls is refused, not truncated." +
|
|
59
29
|
" " +
|
|
60
30
|
TEXT_BUDGET_DESCRIPTION +
|
|
61
|
-
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, and
|
|
31
|
+
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, and each match its fixed_in while matches are kept; not_affected rows leave the text first, then not_assessed ones, and not_sent_purls keeps its first 10.";
|
|
62
32
|
// #2783: a production row is about 2,200 characters as pretty JSON, most of it its matches, each
|
|
63
33
|
// a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
|
|
64
34
|
// text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
|
|
65
|
-
// keeps its verdict, counts and
|
|
35
|
+
// keeps its verdict, counts, cve_ids, matched_package and matched_via (#2836), and each match its
|
|
36
|
+
// scores, flags, fixed_in (#2834) and match_reason (the
|
|
66
37
|
// advisory interval the version falls inside: "[A, B)" names the fixed version B, "[A, B]" the last
|
|
67
38
|
// affected one, #2830) without the
|
|
68
39
|
// description; then the same without match_reason; then only index, purl, verdict,
|
|
@@ -78,8 +49,10 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
78
49
|
// reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
|
|
79
50
|
// points to it.
|
|
80
51
|
export function sbomText(sent) {
|
|
81
|
-
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed"];
|
|
82
|
-
const row = [
|
|
52
|
+
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in"];
|
|
53
|
+
const row = [
|
|
54
|
+
"index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids", "matched_package", "matched_via",
|
|
55
|
+
];
|
|
83
56
|
return {
|
|
84
57
|
rows: "results",
|
|
85
58
|
levels: [
|
|
@@ -101,18 +74,6 @@ export function sbomText(sent) {
|
|
|
101
74
|
page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
|
|
102
75
|
};
|
|
103
76
|
}
|
|
104
|
-
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
105
|
-
function dedupe(all) {
|
|
106
|
-
const seen = new Set();
|
|
107
|
-
const purls = [];
|
|
108
|
-
for (const p of all) {
|
|
109
|
-
if (seen.has(p))
|
|
110
|
-
continue;
|
|
111
|
-
seen.add(p);
|
|
112
|
-
purls.push(p);
|
|
113
|
-
}
|
|
114
|
-
return { purls, removed: all.length - purls.length };
|
|
115
|
-
}
|
|
116
77
|
// The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
|
|
117
78
|
// metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
|
|
118
79
|
function fromCycloneDX(doc) {
|
|
@@ -218,19 +179,7 @@ export function extract(a) {
|
|
|
218
179
|
return 'sbom is neither a CycloneDX JSON document (bomFormat "CycloneDX") nor an SPDX JSON document (spdxVersion "SPDX-…")';
|
|
219
180
|
}
|
|
220
181
|
// ── The answer ──
|
|
221
|
-
const field = (o, k) => (isObj(o) ? o[k] : undefined);
|
|
222
|
-
const count = (o, k) => {
|
|
223
|
-
const v = field(o, k);
|
|
224
|
-
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
225
|
-
};
|
|
226
|
-
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
227
182
|
function noteFor(head, x, d, g) {
|
|
228
|
-
const s = field(d, "summary");
|
|
229
|
-
const affected = count(s, "affected") ?? 0;
|
|
230
|
-
const notAffected = count(s, "not_affected") ?? 0;
|
|
231
|
-
const undetermined = count(s, "undetermined") ?? 0;
|
|
232
|
-
const notAssessed = count(s, "not_assessed") ?? 0;
|
|
233
|
-
const components = count(s, "components") ?? g.sent;
|
|
234
183
|
const out = [head];
|
|
235
184
|
if (x.input === "purls") {
|
|
236
185
|
out.push(`Sent ${plural(g.sent, "purl", "purls")}${x.duplicates_removed ? ` (${plural(x.duplicates_removed, "duplicate was", "duplicates were")} sent once)` : ""}.`);
|
|
@@ -240,134 +189,14 @@ function noteFor(head, x, d, g) {
|
|
|
240
189
|
const unit = x.input === "cyclonedx" ? "component" : "package";
|
|
241
190
|
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 ${g.sent}.`);
|
|
242
191
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
// #2799: the position, not only the list. Past DATA_TEXT_BUDGET the first text block keeps the
|
|
250
|
-
// first 10 of data.not_sent_purls (sbomText), so a model that reads the text alone cannot send
|
|
251
|
-
// "that list"; the purls not sent are always the input's distinct purls from position sent + 1 on
|
|
252
|
-
// (x.purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
|
|
253
|
-
// the order they are counted in (READ_ORDER), which this sentence says whenever it gives the
|
|
254
|
-
// position, not only when the text cuts the list.
|
|
255
|
-
const notSent = g.not_sent_purls.length;
|
|
256
|
-
if (notSent > 0) {
|
|
257
|
-
const one = notSent === 1;
|
|
258
|
-
const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
|
|
259
|
-
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${READ_ORDER}, and data.not_sent_purls lists ${one ? "it" : "them"}: call check_sbom again with purls set to ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
260
|
-
}
|
|
261
|
-
out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
|
|
262
|
-
const by = field(s, "not_assessed_by_reason");
|
|
263
|
-
if (isObj(by)) {
|
|
264
|
-
const parts = Object.entries(by)
|
|
265
|
-
.filter(([, n]) => typeof n === "number" && n > 0)
|
|
266
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
267
|
-
.map(([r, n]) => `${r} ${n}`);
|
|
268
|
-
if (parts.length)
|
|
269
|
-
out.push(`Not assessed, by not_assessed_reason: ${parts.join(", ")}.`);
|
|
270
|
-
const tb = count(by, "time_budget") ?? 0;
|
|
271
|
-
if (tb > 0) {
|
|
272
|
-
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.`);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
const rows = field(d, "results");
|
|
276
|
-
if (Array.isArray(rows)) {
|
|
277
|
-
const hits = rows.filter((r) => field(r, "verdict") === "affected");
|
|
278
|
-
if (hits.length) {
|
|
279
|
-
const named = hits.slice(0, NOTE_AFFECTED_MAX).map((r) => {
|
|
280
|
-
const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
|
|
281
|
-
const ids = field(r, "cve_ids");
|
|
282
|
-
const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
|
|
283
|
-
const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
|
|
284
|
-
return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
|
|
285
|
-
});
|
|
286
|
-
out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return { note: out.join(" "), assessed: affected + notAffected + undetermined };
|
|
192
|
+
// The batches, the waits and the purls not sent, at their position in READ_ORDER (#2799).
|
|
193
|
+
out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom again with purls set to"));
|
|
194
|
+
const v = verdictSentences(d, g);
|
|
195
|
+
out.push(...v.sentences);
|
|
196
|
+
return { note: out.join(" "), assessed: v.assessed };
|
|
290
197
|
}
|
|
291
|
-
// Summary fields summed over the batches, and bounds that hold per batch (the largest held for all).
|
|
292
|
-
const SUMMED = ["components", "affected", "not_affected", "undetermined", "not_assessed", "lookups", "elapsed_ms"];
|
|
293
|
-
const MAXED = ["time_budget_ms", "corpus_cache_max_age_ms"];
|
|
294
|
-
// The batches' answers as one (#2734): results concatenated with index counted across batches
|
|
295
|
-
// (batch b's row i becomes index b*MAX_COMPONENTS+i, its position in the list sent), summaries
|
|
296
|
-
// summed, not_assessed_by_reason summed per reason, partial true when any batch's was, answered_at
|
|
297
|
-
// the last batch's. A count some batch did not send is left out, never summed as 0.
|
|
298
|
-
export function mergeAnswers(answers) {
|
|
299
|
-
const out = { ...answers[0] };
|
|
300
|
-
const results = [];
|
|
301
|
-
answers.forEach((a, b) => {
|
|
302
|
-
const rows = field(a, "results");
|
|
303
|
-
if (!Array.isArray(rows))
|
|
304
|
-
return;
|
|
305
|
-
for (const r of rows) {
|
|
306
|
-
const i = field(r, "index");
|
|
307
|
-
results.push(isObj(r) && typeof i === "number" ? { ...r, index: i + b * MAX_COMPONENTS } : r);
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
const total = (vs) => vs.every((v) => typeof v === "number" && Number.isFinite(v)) ? vs.reduce((t, v) => t + v, 0) : undefined;
|
|
311
|
-
const comps = total(answers.map((a) => field(a, "components")));
|
|
312
|
-
if (comps === undefined)
|
|
313
|
-
delete out.components;
|
|
314
|
-
else
|
|
315
|
-
out.components = comps;
|
|
316
|
-
const ss = answers.map((a) => field(a, "summary"));
|
|
317
|
-
if (ss.every(isObj)) {
|
|
318
|
-
const sums = ss;
|
|
319
|
-
const m = { ...sums[0] };
|
|
320
|
-
for (const k of SUMMED) {
|
|
321
|
-
const t = total(sums.map((x) => x[k]));
|
|
322
|
-
if (t === undefined)
|
|
323
|
-
delete m[k];
|
|
324
|
-
else
|
|
325
|
-
m[k] = t;
|
|
326
|
-
}
|
|
327
|
-
for (const k of MAXED) {
|
|
328
|
-
const vs = sums.map((x) => x[k]).filter((v) => typeof v === "number");
|
|
329
|
-
if (vs.length)
|
|
330
|
-
m[k] = Math.max(...vs);
|
|
331
|
-
}
|
|
332
|
-
if (sums.some((x) => typeof x.partial === "boolean"))
|
|
333
|
-
m.partial = sums.some((x) => x.partial === true);
|
|
334
|
-
if (sums.some((x) => isObj(x.not_assessed_by_reason))) {
|
|
335
|
-
const by = {};
|
|
336
|
-
for (const x of sums) {
|
|
337
|
-
if (!isObj(x.not_assessed_by_reason))
|
|
338
|
-
continue;
|
|
339
|
-
for (const [r, n] of Object.entries(x.not_assessed_by_reason))
|
|
340
|
-
if (typeof n === "number")
|
|
341
|
-
by[r] = (by[r] ?? 0) + n;
|
|
342
|
-
}
|
|
343
|
-
m.not_assessed_by_reason = by;
|
|
344
|
-
}
|
|
345
|
-
out.summary = m;
|
|
346
|
-
}
|
|
347
|
-
else {
|
|
348
|
-
delete out.summary;
|
|
349
|
-
}
|
|
350
|
-
const last = answers[answers.length - 1];
|
|
351
|
-
if (field(last, "answered_at") !== undefined)
|
|
352
|
-
out.answered_at = field(last, "answered_at");
|
|
353
|
-
out.results = results;
|
|
354
|
-
return out;
|
|
355
|
-
}
|
|
356
|
-
const realSleep = (ms, signal) => new Promise((r) => {
|
|
357
|
-
if (signal?.aborted)
|
|
358
|
-
return r();
|
|
359
|
-
const done = () => {
|
|
360
|
-
clearTimeout(timer);
|
|
361
|
-
signal?.removeEventListener("abort", done);
|
|
362
|
-
r();
|
|
363
|
-
};
|
|
364
|
-
const timer = setTimeout(done, ms);
|
|
365
|
-
signal?.addEventListener("abort", done, { once: true });
|
|
366
|
-
});
|
|
367
198
|
export async function checkSbom(deps, a) {
|
|
368
199
|
const tool = CHECK_SBOM;
|
|
369
|
-
const sleep = deps.sleep ?? realSleep;
|
|
370
|
-
const now = deps.now ?? Date.now;
|
|
371
200
|
try {
|
|
372
201
|
const x = extract(a);
|
|
373
202
|
if (typeof x === "string")
|
|
@@ -375,103 +204,16 @@ export async function checkSbom(deps, a) {
|
|
|
375
204
|
if (x.purls.length === 0) {
|
|
376
205
|
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`);
|
|
377
206
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const started = now();
|
|
388
|
-
const budgetS = TIME_BUDGET_MS / 1000;
|
|
389
|
-
const answers = [];
|
|
390
|
-
let status = 200;
|
|
391
|
-
let waits = 0;
|
|
392
|
-
let waitedMs = 0;
|
|
393
|
-
let stop = null;
|
|
394
|
-
// The 429 last waited out, for the one way the budget can be gone before anything answered.
|
|
395
|
-
let waitedFor;
|
|
396
|
-
// One batch at a time, in order; a 429 with Retry-After is waited out and the same batch sent
|
|
397
|
-
// again, while the wait fits in the call's budget.
|
|
398
|
-
while (answers.length < batches.length) {
|
|
399
|
-
const left = TIME_BUDGET_MS - (now() - started);
|
|
400
|
-
if (left <= 0 && answers.length === 0 && waitedFor) {
|
|
401
|
-
// A wait that ended at the budget's end: no time is left to send the first batch again, and
|
|
402
|
-
// nothing was measured, so that 429 is the answer, not a request given no time at all.
|
|
403
|
-
return deps.failed(tool, waitedFor);
|
|
404
|
-
}
|
|
405
|
-
if (answers.length > 0 && left <= 0) {
|
|
406
|
-
stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out after ${answers.length} of ${batches.length} batches` };
|
|
407
|
-
break;
|
|
408
|
-
}
|
|
409
|
-
// At most what is left of the budget (#2756): a slow batch is cut off at its end.
|
|
410
|
-
const r = await deps.api(BATCH_PATH, {
|
|
411
|
-
method: "POST",
|
|
412
|
-
headers: { "Content-Type": "application/json" },
|
|
413
|
-
body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
|
|
414
|
-
timeoutMs: Math.max(0, left),
|
|
415
|
-
signal: deps.signal,
|
|
416
|
-
});
|
|
417
|
-
if (r.ok) {
|
|
418
|
-
answers.push(r.data);
|
|
419
|
-
status = r.status;
|
|
420
|
-
continue;
|
|
421
|
-
}
|
|
422
|
-
// Cancelled (#2775): this batch was cut off, or never sent after a wait the signal ended.
|
|
423
|
-
// Nothing more is sent, and the answer goes nowhere.
|
|
424
|
-
if (deps.signal?.aborted)
|
|
425
|
-
return deps.failed(tool, r);
|
|
426
|
-
if (r.kind === "http" && r.status === 429) {
|
|
427
|
-
if (r.retryAfter === undefined) {
|
|
428
|
-
stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
|
|
429
|
-
}
|
|
430
|
-
else if (waits >= MAX_WAITS) {
|
|
431
|
-
stop = { reason: "rate_limited", detail: `the API answered 429 again after ${MAX_WAITS} waits` };
|
|
432
|
-
}
|
|
433
|
-
else {
|
|
434
|
-
const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
|
|
435
|
-
if (now() - started + waitMs <= TIME_BUDGET_MS) {
|
|
436
|
-
await sleep(waitMs, deps.signal);
|
|
437
|
-
waits++;
|
|
438
|
-
waitedMs += waitMs;
|
|
439
|
-
waitedFor = r;
|
|
440
|
-
continue;
|
|
441
|
-
}
|
|
442
|
-
stop = { reason: "time_budget", detail: `the API answered 429 asking for a wait of ${r.retryAfter} s (Retry-After), which would pass the call's ${budgetS} s budget` };
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
else if (r.kind === "timeout" && now() - started >= TIME_BUDGET_MS) {
|
|
446
|
-
// Cut off by the budget, not by the API failing: the purls are not sent for want of time.
|
|
447
|
-
stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out while batch ${answers.length + 1} of ${batches.length} was unanswered, so it was cut off` };
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
stop = { reason: "request_failed", detail: deps.describeFailure(r).replace(/\.$/, "") };
|
|
451
|
-
}
|
|
452
|
-
// Nothing answered yet: nothing was measured, so the failure is the answer.
|
|
453
|
-
if (answers.length === 0)
|
|
454
|
-
return deps.failed(tool, r);
|
|
455
|
-
break;
|
|
456
|
-
}
|
|
457
|
-
const notSentPurls = x.purls.slice(answers.length * MAX_COMPONENTS);
|
|
458
|
-
const g = {
|
|
459
|
-
batches: batches.length,
|
|
460
|
-
batches_sent: answers.length,
|
|
461
|
-
sent: x.purls.length - notSentPurls.length,
|
|
462
|
-
not_sent_purls: notSentPurls,
|
|
463
|
-
not_sent_reason: notSentPurls.length ? (stop?.reason ?? null) : null,
|
|
464
|
-
not_sent_detail: notSentPurls.length ? (stop?.detail ?? null) : null,
|
|
465
|
-
waits,
|
|
466
|
-
waited_ms: waitedMs,
|
|
467
|
-
};
|
|
468
|
-
// One batch: data is the API's JSON as it came. More: the merged answer.
|
|
469
|
-
const data = batches.length === 1 ? answers[0] : mergeAnswers(answers);
|
|
470
|
-
if (notSentPurls.length)
|
|
471
|
-
data.not_sent_purls = notSentPurls;
|
|
207
|
+
const refused = purlsRefusal(x.purls, `split the list into calls of ${MAX_PURLS} or fewer`);
|
|
208
|
+
if (refused)
|
|
209
|
+
return deps.badInput(tool, refused);
|
|
210
|
+
// match_batch.ts: one batch at a time, Retry-After waited out within the budget (#2734,
|
|
211
|
+
// #2756), stopped on cancellation (#2775).
|
|
212
|
+
const run = await sendBatches(deps, tool, x.purls);
|
|
213
|
+
if (!run.ok)
|
|
214
|
+
return run.result;
|
|
215
|
+
const { data, status, sending: g } = run;
|
|
472
216
|
const { note, assessed } = noteFor(deps.okHead(tool, status), x, data, g);
|
|
473
|
-
const s = field(data, "summary");
|
|
474
|
-
const summaryPartial = typeof field(s, "partial") === "boolean" ? field(s, "partial") : null;
|
|
475
217
|
return deps.succeeded(data, note, {
|
|
476
218
|
state: assessed > 0 ? "measured" : "not_assessed",
|
|
477
219
|
measured_at: null,
|
|
@@ -482,17 +224,7 @@ export async function checkSbom(deps, a) {
|
|
|
482
224
|
with_purl: x.with_purl,
|
|
483
225
|
without_purl: x.without_purl,
|
|
484
226
|
duplicates_removed: x.duplicates_removed,
|
|
485
|
-
|
|
486
|
-
batch_size: MAX_COMPONENTS,
|
|
487
|
-
batches: g.batches,
|
|
488
|
-
batches_sent: g.batches_sent,
|
|
489
|
-
sent: g.sent,
|
|
490
|
-
not_sent: notSentPurls.length,
|
|
491
|
-
not_sent_reason: g.not_sent_reason,
|
|
492
|
-
rate_limit_waits: waits,
|
|
493
|
-
waited_ms: waitedMs,
|
|
494
|
-
not_assessed: count(s, "not_assessed") ?? null,
|
|
495
|
-
partial: notSentPurls.length > 0 ? true : summaryPartial,
|
|
227
|
+
...sendingCoverage(g, x.purls.length, field(data, "summary")),
|
|
496
228
|
},
|
|
497
229
|
freshness: null,
|
|
498
230
|
notes: [DISTRO_NOTE, CLEAN_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE],
|
|
@@ -503,86 +235,17 @@ export async function checkSbom(deps, a) {
|
|
|
503
235
|
}
|
|
504
236
|
}
|
|
505
237
|
// ── Schemas ──
|
|
506
|
-
const opt = (t) => t.nullable().optional();
|
|
507
|
-
const Row = z.looseObject({
|
|
508
|
-
index: opt(z.number()),
|
|
509
|
-
input_kind: opt(z.string()),
|
|
510
|
-
purl: opt(z.string()),
|
|
511
|
-
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)."),
|
|
512
|
-
ecosystem: opt(z.string()),
|
|
513
|
-
package: opt(z.string()),
|
|
514
|
-
version: opt(z.string()),
|
|
515
|
-
assessed: opt(z.boolean()).describe("Whether the matcher produced a verdict for this component. false is never clean."),
|
|
516
|
-
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."),
|
|
517
|
-
code: opt(z.string()),
|
|
518
|
-
error: opt(z.string()),
|
|
519
|
-
cve_ids: opt(z.array(z.string())),
|
|
520
|
-
matches: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
521
|
-
count: opt(z.number()),
|
|
522
|
-
advisories_considered: opt(z.number()),
|
|
523
|
-
not_affected_count: opt(z.number()),
|
|
524
|
-
undetermined_count: opt(z.number()),
|
|
525
|
-
undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
526
|
-
capped: opt(z.boolean()),
|
|
527
|
-
candidates_capped: opt(z.boolean()),
|
|
528
|
-
});
|
|
529
238
|
export function checkSbomOutput(envelopeSchema) {
|
|
530
239
|
return envelopeSchema({
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
components: opt(z.number()),
|
|
534
|
-
summary: z
|
|
535
|
-
.looseObject({
|
|
536
|
-
components: opt(z.number()),
|
|
537
|
-
affected: opt(z.number()),
|
|
538
|
-
not_affected: opt(z.number()).describe("Components with a decided, clean verdict."),
|
|
539
|
-
undetermined: opt(z.number()).describe("Components whose advisories could not all be decided and none matched: not clean."),
|
|
540
|
-
not_assessed: opt(z.number()).describe("Components with no verdict: not clean."),
|
|
541
|
-
not_assessed_by_reason: opt(z.looseObject({
|
|
542
|
-
package_not_in_advisory_corpus: z.number().optional(),
|
|
543
|
-
no_decidable_advisory: z.number().optional(),
|
|
544
|
-
advisory_lookup_failed: z.number().optional(),
|
|
545
|
-
candidate_window_truncated: z.number().optional(),
|
|
546
|
-
time_budget: z.number().optional().describe("Components the batch's time budget ran out before: not clean; check them again."),
|
|
547
|
-
distro_release_unknown: z.number().optional().describe("deb, apk or rpm purls without a distro qualifier naming the release, which EchelonGraph does not guess."),
|
|
548
|
-
purl_type_unsupported: z.number().optional(),
|
|
549
|
-
version_missing: z.number().optional(),
|
|
550
|
-
invalid_component: z.number().optional(),
|
|
551
|
-
})).describe("The not_assessed components, counted by not_assessed_reason."),
|
|
552
|
-
lookups: opt(z.number()),
|
|
553
|
-
partial: opt(z.boolean()).describe("true when the time budget ran out before every component was looked up."),
|
|
554
|
-
time_budget_ms: opt(z.number()),
|
|
555
|
-
elapsed_ms: opt(z.number()),
|
|
556
|
-
corpus_cache_max_age_ms: opt(z.number()),
|
|
557
|
-
})
|
|
558
|
-
.optional(),
|
|
559
|
-
answered_at: opt(z.string()),
|
|
560
|
-
results: z.array(Row).optional(),
|
|
561
|
-
not_sent_purls: opt(z.array(z.string())).describe("The distinct purls not sent (coverage.not_sent_reason says why): not checked, and not clean. Present only when some were not sent."),
|
|
562
|
-
}),
|
|
240
|
+
// match_batch.ts batchDataShape: the batch route's answer, merged over the batches.
|
|
241
|
+
data: z.looseObject(batchDataShape),
|
|
563
242
|
coverage: z.strictObject({
|
|
564
243
|
input: z.enum(["purls", "cyclonedx", "spdx"]).describe("What was passed: a purl list, a CycloneDX JSON document or an SPDX JSON document."),
|
|
565
244
|
components_in_document: z.number().int().nullable().describe("Components (CycloneDX) or packages (SPDX) in the document; null for a purl list."),
|
|
566
245
|
with_purl: z.number().int().describe("Of those, the ones carrying a purl."),
|
|
567
246
|
without_purl: z.number().int().describe("The ones without a purl: not checked, and not clean."),
|
|
568
247
|
duplicates_removed: z.number().int().describe("Purls that appeared more than once and were sent once."),
|
|
569
|
-
|
|
570
|
-
batch_size: z.number().int().describe("The most purls one request carries (the API's cap per request)."),
|
|
571
|
-
batches: z.number().int().describe("Requests the distinct purls make, at batch_size each."),
|
|
572
|
-
batches_sent: z.number().int().describe("Of those, the ones the API answered."),
|
|
573
|
-
sent: z.number().int().describe("Distinct purls sent and answered."),
|
|
574
|
-
not_sent: z.number().int().describe("Distinct purls not sent, listed in data.not_sent_purls: not checked, and not clean."),
|
|
575
|
-
not_sent_reason: z
|
|
576
|
-
.enum(["time_budget", "rate_limited", "request_failed"])
|
|
577
|
-
.nullable()
|
|
578
|
-
.describe("Why not_sent is above 0: time_budget (the call's 50 s budget ran out, before a batch or while one was unanswered, which is then cut off; or waiting out the API's Retry-After would pass it), rate_limited (a 429 without Retry-After, or a 429 after 10 waits), request_failed (a batch after the first failed; the note quotes how). null when every purl was sent."),
|
|
579
|
-
rate_limit_waits: z.number().int().describe("How many times the API answered 429 and the tool waited its Retry-After before sending the batch again."),
|
|
580
|
-
waited_ms: z.number().int().describe("Milliseconds spent in those waits."),
|
|
581
|
-
not_assessed: z.number().int().nullable().describe("Of those sent, the components with no verdict, as the answer counts them."),
|
|
582
|
-
partial: z
|
|
583
|
-
.boolean()
|
|
584
|
-
.nullable()
|
|
585
|
-
.describe("true when not every purl was sent (not_sent above 0) or any batch's summary.partial was true (its time budget ran out first); otherwise the answer's summary.partial."),
|
|
248
|
+
...sendingCoverageShape,
|
|
586
249
|
}),
|
|
587
250
|
freshness: null,
|
|
588
251
|
});
|
package/dist/tools/cve_intel.js
CHANGED
|
@@ -70,7 +70,7 @@ const TIMELINE_RELAYED = 20;
|
|
|
70
70
|
const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
|
|
71
71
|
const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
|
|
72
72
|
const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed is the fix for it, and a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
|
|
73
|
-
export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total
|
|
73
|
+
export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (one row per fixed version, with the vulnerable_range it fixes) and timeline (the newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range of the package on record (introduced, and fixed or last_affected, with advisory_id (the OSV record that published the range) and source); in the ecosystem's version order, a version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range's fixed is the fix for it. fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is a label from the source, not a guarantee that the exploit works against a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which of them are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Pass a CVE ID like CVE-2021-44228. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
|
|
74
74
|
// ── outputSchema ──
|
|
75
75
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
76
76
|
const CountMap = z.record(z.string(), z.number());
|