echelongraph-mcp 2.6.8 → 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.
@@ -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
- export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 2,000 distinct) 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 by this MCP server and only they are sent to the API, in POST bodies of at most 200 purls each, one after another, 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. 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 (index counts across batches), 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, summed over the batches (partial is true when any batch's was). 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. The API allows 1,200 components a minute per caller; when it answers 429 with Retry-After, the tool waits as asked and sends the batch again, within 50 seconds per call. When the next wait would pass that, or a batch after the first fails, the tool stops and answers what it has: coverage.not_sent counts the purls not sent and coverage.not_sent_reason says why (time_budget, rate_limited or request_failed), data.not_sent_purls lists them for a later call, and they are not checked and not clean. A list or document with more than 2,000 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, what was sent in how many batches, and what was not sent and why), freshness (null) and notes, with data equal to the API's JSON (for more than one batch, the batches' answers merged); 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." +
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 the rows whose verdict is not_affected, then not_assessed, are left out of the text before any other; not_sent_purls keeps its first 10, and the note says from which position of the input the purls not sent run.";
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 cve_ids, and each match its scores, flags and match_reason (the
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 = ["index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids"];
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
- if (g.batches > 1) {
244
- out.push(`The ${x.purls.length} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
245
- }
246
- if (g.waits > 0) {
247
- out.push(`The API's component budget (${API_COMPONENTS_PER_MINUTE} components a minute) ran out ${g.waits === 1 ? "once" : `${g.waits} times`}: the tool waited ${Math.round(g.waited_ms / 1000)} s in all, as its Retry-After asked, and sent the batch again.`);
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
- if (x.purls.length > MAX_PURLS) {
379
- return deps.badInput(tool, `${x.purls.length} distinct purls; at most ${MAX_PURLS} are checked per call (in batches of ${MAX_COMPONENTS}, within the API's budget of ${API_COMPONENTS_PER_MINUTE} components a minute), and none is dropped silently: split the list into calls of ${MAX_PURLS} or fewer`);
380
- }
381
- const long = x.purls.findIndex((p) => p.length > MAX_PURL_LEN);
382
- if (long >= 0)
383
- return deps.badInput(tool, `purl ${long + 1} is longer than ${MAX_PURL_LEN} characters`);
384
- const batches = [];
385
- for (let i = 0; i < x.purls.length; i += MAX_COMPONENTS)
386
- batches.push(x.purls.slice(i, i + MAX_COMPONENTS));
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
- distinct_purls: x.purls.length,
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
- data: z.looseObject({
532
- match_layer: opt(z.string()),
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
- distinct_purls: z.number().int().describe("Distinct purls to check: sent plus not_sent."),
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
  });
@@ -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 (every reference on record), exploits_capped (true when exploits lists fewer than exploits_total), exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (ecosystem, package_name, vulnerable_range, fixed_version: one row per fixed version, with the vulnerable_range it fixes where the source gives one) and timeline (the newest enrichment-history rows, with timeline_total). ${FIXED_BRANCHES_DESCRIPTION} ${VERIFIED_STATUS_DESCRIPTION} 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. Vendor advisories, patches, generated summaries, trending signals and historical incidents are not relayed. Pass a CVE ID like CVE-2021-44228. Its structured result carries state (measured), measured_at (null: each row carries its own time), method, coverage (the sections relayed, failed and left out, and per-list counts), freshness (null) and notes, with data, which the first text block holds whole up to 30,000 characters; the result's last text block repeats it without data and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
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());