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/dist/index.js CHANGED
@@ -56,13 +56,16 @@ import { registerKevRecent } from "./tools/kev_recent.js";
56
56
  import { registerEPSSHistory } from "./tools/epss_history.js";
57
57
  import { registerCheckAffected } from "./tools/check_affected.js";
58
58
  import { registerCheckSbom } from "./tools/check_sbom.js";
59
+ import { registerScanManifest } from "./tools/scan_manifest.js";
59
60
  import { registerCveIntel } from "./tools/cve_intel.js";
61
+ import { registerCveRemediation } from "./tools/cve_remediation.js";
60
62
  import { registerGetCwe } from "./tools/get_cwe.js";
61
63
  import { holdRequest, isHttpEntrypoint, requestSignal, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
62
64
  import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
63
65
  import { registerPrompts } from "./prompts.js";
64
66
  import { registerResources } from "./resources.js";
65
67
  import { dataText, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "./textBudget.js";
68
+ import { SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.js";
66
69
  // The package actually running, read from the package.json that ships beside dist/. The MCP
67
70
  // handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
68
71
  // access log can count adoption per version without a code change per release.
@@ -652,7 +655,7 @@ const SEARCH_CVES_TEXT = {
652
655
  rows: "cves",
653
656
  levels: [
654
657
  {
655
- keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "exploit_poc_available"],
658
+ keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "patch_evidence", "exploit_poc_available"],
656
659
  clip: 200,
657
660
  },
658
661
  { keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "published"], clip: 100 },
@@ -740,6 +743,29 @@ function cpeConfigurationsNote(rec) {
740
743
  }
741
744
  return "No cpe_configurations in the answer: EchelonGraph holds no stored NVD configuration tree for this CVE, which does not mean no product is affected; cpe_match lists its CPE criteria flattened, without the AND/OR operators.";
742
745
  }
746
+ // #2839: whose text kev_required_action is. One sentence in the note, only where the record is
747
+ // KEV-listed and carries CISA's text; a record without it (not listed, or an API older than the
748
+ // fields) says nothing more.
749
+ function kevTextNote(rec) {
750
+ if (field(rec, "kev_listed") !== true)
751
+ return "";
752
+ const has = ["kev_required_action", "kev_short_description", "kev_notes_urls"].some((k) => field(rec, k) !== undefined && field(rec, k) !== null);
753
+ return has
754
+ ? " kev_required_action, kev_short_description and kev_notes_urls are CISA's own text from its KEV catalog, relayed verbatim: CISA's required action is directed at US federal civilian agencies under Binding Operational Directive 22-01 and is not EchelonGraph's advice."
755
+ : "";
756
+ }
757
+ // #2841: one sentence on the record's remediation summary, where the API sends one.
758
+ function remediationSummaryNote(cve, rec) {
759
+ const s = field(rec, "remediation");
760
+ if (s === null || typeof s !== "object")
761
+ return "";
762
+ const kinds = field(s, "vendor_remediation_kinds");
763
+ const counted = kinds && typeof kinds === "object" ? Object.entries(kinds).filter(([, n]) => typeof n === "number" && n > 0) : [];
764
+ const failed = field(s, "failed_sections");
765
+ const notRead = Array.isArray(failed) && failed.length ? ` The summary could not read ${failed.join(", ")} (failed_sections), so a missing count there is not a finding of none.` : "";
766
+ const byKind = counted.length ? ` Vendor advisories list ${counted.map(([k, n]) => `${k} (${n})`).join(", ")}, by the vendor's own category.` : "";
767
+ return ` remediation summarises how ${cve} is fixed, as its sources state it; cve_remediation returns each vendor's text, CISA's action and every fixed range.${byKind}${notRead}`;
768
+ }
743
769
  async function getCVE(cve_id) {
744
770
  const tool = "get_cve";
745
771
  const id = cve_id.trim();
@@ -751,7 +777,7 @@ async function getCVE(cve_id) {
751
777
  return failed(tool, r);
752
778
  const written = strAt(r.data, "updated_at");
753
779
  const cve = strAt(r.data, "cve_id") ?? id;
754
- return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}`, {
780
+ return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}${kevTextNote(r.data)}${remediationSummaryNote(cve, r.data)}`, {
755
781
  state: "measured",
756
782
  measured_at: instantOrNull(written),
757
783
  method: CVE_FEED_METHOD,
@@ -787,21 +813,19 @@ async function getCVE(cve_id) {
787
813
  // reconcileStale) finds the service's PORT still listed, without re-reading the version. So a
788
814
  // patched service can stay counted while its port stays open, and last_seen is our write time,
789
815
  // not the time Shodan saw the service: on 2026-09-27 production's 50 newest rows carried
790
- // last_seen within 0.78 s of one another, a batch write. Shodan's own banner timestamp is not
791
- // stored.
816
+ // last_seen within 0.78 s of one another, a batch write. Shodan's own banner timestamp is
817
+ // stored since #2438 (banner_seen_at), but only the stats answer serves it, as observed_window;
818
+ // this per-CVE answer does not.
792
819
  // Every text calls last_seen a write or refresh time, never a sighting ("last seen listening",
793
820
  // "re-seen", "most recently seen"), and it is never measured_at (see observedAt).
794
821
  export const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
795
822
  // Quoted when the API does not send its own `method` string.
796
823
  const EXPOSURE_METHOD = "Shodan banner match on the radar's tracked products; up to 100 ip:port services per product query; searched every 12 h when Shodan query credits allow";
797
- const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
798
- // Shodan's terms (https://static.shodan.io/legal/terms.html, read 2026-09-27) ask for two
799
- // things: "You must attribute such usage to Shodan", and materials "referencing, including or
800
- // otherwise based on Shodan information or materials, must clearly indicate Shodan's ownership
801
- // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
802
- // first; this sentence, placed beside it wherever Shodan data is named, does the second.
803
- const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
804
- const CVE_EXPOSURE_DESCRIPTION = `Internet-exposure footprint for one CVE from EchelonGraph's KEV-exposure radar: how many internet-facing services (distinct ip:port, returned as exposed_hosts; a machine answering on two ports counts twice) the radar has on record running a version its CVE matcher maps to this CVE, with a country/product breakdown and a ransomware flag. Aggregate and host-redacted; free and keyless. Method: exposure counts are derived from Shodan data. ${SHODAN_OWNERSHIP} Every 12 h, when Shodan query credits allow, the radar runs one Shodan query per tracked product, reads up to 100 ip:port services per query, and keeps a service when its banner version matches a CISA-KEV or high-EPSS CVE; a service whose row has not been written or refreshed for 21 days is dropped. last_seen is when EchelonGraph last wrote or refreshed a service's row, not when the service was observed and not when its vulnerable version was last confirmed: it is set to the time of the write when a search matches the banner, and again when a re-check finds the port still listed by Shodan InternetDB, without re-reading the banner, so a patched service can stay counted while its port stays open. A count is therefore a banner-version inference over a sample, not an exploit test and not an internet-wide census. The radar only looks for its tracked set of CVEs: for a CVE outside that set the note says NOT ASSESSED (exposure_state not_assessed), and its 0 is not a measurement.`;
824
+ // SHODAN_ATTRIBUTION and SHODAN_OWNERSHIP (#2306) live in requiredText.ts, beside the client cut (#2842).
825
+ // #2842: within the client cut (requiredText.ts); last_seen's full rule is CVE_EXPOSURE_LAST_SEEN,
826
+ // the outputSchema's description of data.last_seen.
827
+ const CVE_EXPOSURE_DESCRIPTION = `Internet-exposure footprint for one CVE from EchelonGraph's KEV-exposure radar: how many internet-facing services (distinct ip:port, returned as exposed_hosts; a machine answering on two ports counts twice) the radar has on record running a version its CVE matcher maps to this CVE, with a country/product breakdown and a ransomware flag. Aggregate and host-redacted; free and keyless. Method: exposure counts are derived from Shodan data. ${SHODAN_OWNERSHIP} Every 12 h, when Shodan query credits allow, the radar runs one Shodan query per tracked product, reads up to 100 ip:port services per query, and keeps a service when its banner version matches a CISA-KEV or high-EPSS CVE; a service whose row has not been written or refreshed for 21 days is dropped. The radar only looks for its tracked set of CVEs: for a CVE outside that set the note says NOT ASSESSED (exposure_state not_assessed), and its 0 is not a measurement. last_seen is when EchelonGraph last wrote or refreshed a service's row, not when the service was observed: a port still listed by Shodan InternetDB refreshes it without re-reading the banner, so a patched service can stay counted while its port stays open. A count is therefore a banner-version inference over a sample, not an exploit test and not an internet-wide census.`;
828
+ const CVE_EXPOSURE_LAST_SEEN = `When EchelonGraph last wrote or refreshed a service's row, not when the service was observed and not when its vulnerable version was last confirmed: it is set to the time of the write when a search matches the banner, and again when a re-check finds the port still listed by Shodan InternetDB, without re-reading the banner, so a patched service can stay counted while its port stays open. ${SHODAN_OWNERSHIP}`;
805
829
  function exposureNote(head, id, d) {
806
830
  const cve = strAt(d, "cve_id") ?? id;
807
831
  const hosts = numAt(d, "exposed_hosts");
@@ -1211,6 +1235,9 @@ const be = (n) => (n === 1 ? "is" : "are");
1211
1235
  // to COUNT(DISTINCT host) of their KEV-flagged rows, COALESCE(e.h, 0).
1212
1236
  // last_run_at the radar's last completed check, KeyKEVExposureCheckCompleted (#2335; see
1213
1237
  // LAST_CHECK). Its own row: KeyKEVExposure is the Shodan-credit watermark.
1238
+ // observed_window MIN/MAX(banner_seen_at) and the rows with none, over the same KEV rows,
1239
+ // in the headline's statement (#2438): when Shodan collected the banners
1240
+ // matched. The InternetDB re-check (Touch) never moves it; last_seen is not it.
1214
1241
  // So newest_kev[].exposed_hosts is 0 for every CVE the radar holds no row for, whether or not
1215
1242
  // it could ever hold one: the join default, not a measurement. The per-CVE endpoint decides
1216
1243
  // that with `tracked` (store.go trackedVerdict), which is true ONLY when rows exist
@@ -1243,6 +1270,7 @@ const KEV_SPEC = {
1243
1270
  },
1244
1271
  },
1245
1272
  instants: ["generated_at", "last_run_at"],
1273
+ window: "observed_window",
1246
1274
  };
1247
1275
  // One newest_kev row as relayed: the CVE record's fields, its exposure_state, and a count only
1248
1276
  // when the count is a measurement. `tracked` is read, not relayed: exposure_state says it.
@@ -1261,7 +1289,7 @@ function newestKEVRow(r) {
1261
1289
  return { row, state, outside: tracked === false };
1262
1290
  }
1263
1291
  function readKEVExposure(body) {
1264
- const { data, unknown, unusable } = readRadar(body, KEV_SPEC);
1292
+ const { data, unknown, unusable, undated } = readRadar(body, KEV_SPEC);
1265
1293
  const sentences = [];
1266
1294
  const n = (k) => numIn(data, k);
1267
1295
  const totals = [
@@ -1316,6 +1344,7 @@ function readKEVExposure(body) {
1316
1344
  }
1317
1345
  sentences.push("kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability (0 to 1), absent when the record has none: scores, not counts.");
1318
1346
  }
1347
+ sentences.push(...windowSentences("kev_exposure", { window: data.window, undated, bad: false }, "when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, Shodan's own times, not when EchelonGraph stored or re-checked a pair"));
1319
1348
  sentences.push(...lastCheck("kev_exposure", data));
1320
1349
  sentences.push(...leftOut("kev_exposure", unknown, unusable));
1321
1350
  return { data, sentences };
@@ -1795,12 +1824,18 @@ function readMCPServers(src) {
1795
1824
  }
1796
1825
  return { data, sentences };
1797
1826
  }
1798
- const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked credentials sampled from public GitHub push events; and shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. It also gives MCP-server counts: hostnames named like an MCP server in EchelonGraph's own Certificate Transparency feed (no Shodan data), each counted once by its latest verdict from EchelonGraph's identified MCP probe, which never sends tools/call. Every number in the result is labelled here and in the result's note by what it counts; a field this version cannot label is left out and named in the note. ` +
1799
- `kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and this answer does not say whether the radar tracks that CVE: tracking is answered per CVE. kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability. ` +
1800
- `exposed_databases: exposed_databases.distinct_hosts counts distinct ip:port services the check confirmed answering without authentication, and exposed_databases.engines the distinct engine types among them; exposed_databases.top_engines and exposed_databases.top_countries rank up to 15 engines and 10 countries by those services. exposed_databases.pii_likely and exposed_databases.pci_likely count services whose schema names (never record values) pass a high-confidence, precision-first gate for personal or payment-card data, so a service outside them is not shown to hold no such data. exposed_databases.window.from and exposed_databases.window.to are timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted, a span of checks made at different times; with a counted service that has no such time on record there is no window. ` +
1801
- `leaked_credentials: leaked_credentials.total counts (repository, secret) pairs, not distinct secrets, so one secret in three repositories counts three times; leaked_credentials.distinct_secrets counts each secret once and leaked_credentials.distinct_repos counts repositories; leaked_credentials.top_providers and leaked_credentials.top_types rank up to 15 providers and secret types by those pairs. None is validated: each is a credential-shaped string that passed EchelonGraph's filters, at most structurally checked and never tested against its provider. leaked_credentials.window.from and leaked_credentials.window.to are timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the pairs counted. Each of those three radars also carries generated_at, when the API computed its totals, and, when the API can tell, last_run_at. kev_exposure.last_run_at, exposed_databases.last_run_at and leaked_credentials.last_run_at are timestamps, not counts: each is when that radar last completed a check, a cycle whose reads succeeded, among them a Shodan search (for exposed_databases, or its LeakIX fallback) that answered at least one query, or for leaked_credentials a read of the public GitHub event stream. A cycle that read nothing does not move it, and it is not the time of every record a radar's numbers count, which cover everything still on record, not only what the last check found. A radar whose answer carries no last_run_at has none in the result, and the note says nothing about it. ` +
1802
- `shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. confirmed_exposed.window.from and confirmed_exposed.window.to are timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of those services, a span of probes made at different times. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ` +
1803
- `mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them; hostnames whose owner opted out of scanning are not checked again while the opt-out stands, so their last verdict is left out too, and mcp_servers.withheld_opted_out counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
1827
+ // #2842: the description is a head that fits the client cut (requiredText.ts); what each radar's
1828
+ // numbers count, field by field, is the outputSchema's description of that radar (RADAR_*_FIELDS
1829
+ // below), and the result's note labels every number relayed in the same words. Each text below
1830
+ // that names Shodan carries SHODAN_OWNERSHIP (#2306).
1831
+ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars: kev_exposure, internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; exposed_databases, unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked_credentials, sampled from public GitHub push events; and shadow_ai, services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. ${SHODAN_OWNERSHIP} It also gives MCP-server counts: hostnames named like an MCP server in EchelonGraph's own Certificate Transparency feed (no Shodan data), each counted once by its latest verdict from EchelonGraph's identified MCP probe, which never sends tools/call. Service counts are distinct ip:port services, not machines. The result's note, and the outputSchema's description of each radar, label every number by what it counts. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. A kev_exposure.newest_kev CVE whose exposure_state is not_assessed has no measurement in the answer: its 0 is not one.`;
1832
+ const RADAR_KEV_FIELDS = `kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and this answer does not say whether the radar tracks that CVE: tracking is answered per CVE. kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability. kev_exposure.window.from and kev_exposure.window.to are timestamps, not counts: when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, a span of observations made at different times; with a counted pair that has no such time on record there is no window. ${SHODAN_OWNERSHIP}`;
1833
+ const RADAR_DB_FIELDS = `exposed_databases: exposed_databases.distinct_hosts counts distinct ip:port services the check confirmed answering without authentication, and exposed_databases.engines the distinct engine types among them; exposed_databases.top_engines and exposed_databases.top_countries rank up to 15 engines and 10 countries by those services. exposed_databases.pii_likely and exposed_databases.pci_likely count services whose schema names (never record values) pass a high-confidence, precision-first gate for personal or payment-card data, so a service outside them is not shown to hold no such data. exposed_databases.window.from and exposed_databases.window.to are timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted, a span of checks made at different times; with a counted service that has no such time on record there is no window.`;
1834
+ const RADAR_LEAKED_FIELDS = `leaked_credentials: leaked_credentials.total counts (repository, secret) pairs, not distinct secrets, so one secret in three repositories counts three times; leaked_credentials.distinct_secrets counts each secret once and leaked_credentials.distinct_repos counts repositories; leaked_credentials.top_providers and leaked_credentials.top_types rank up to 15 providers and secret types by those pairs. None is validated: each is a credential-shaped string that passed EchelonGraph's filters, at most structurally checked and never tested against its provider. leaked_credentials.window.from and leaked_credentials.window.to are timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the pairs counted.`;
1835
+ // What the three stats radars' last_run_at is, and what is left out: the outputSchema's description of data.
1836
+ const RADAR_LAST_RUN_FIELDS = `A field this version cannot label is left out of data and named in the note. Each of the kev_exposure, exposed_databases and leaked_credentials radars also carries generated_at, when the API computed its totals, and, when the API can tell, last_run_at. kev_exposure.last_run_at, exposed_databases.last_run_at and leaked_credentials.last_run_at are timestamps, not counts: each is when that radar last completed a check, a cycle whose reads succeeded, among them a Shodan search (for exposed_databases, or its LeakIX fallback) that answered at least one query, or for leaked_credentials a read of the public GitHub event stream. A cycle that read nothing does not move it, and it is not the time of every record a radar's numbers count, which cover everything still on record, not only what the last check found. A radar whose answer carries no last_run_at has none in the result, and the note says nothing about it. ${SHODAN_OWNERSHIP}`;
1837
+ const RADAR_SHADOW_AI_FIELDS = `shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. confirmed_exposed.window.from and confirmed_exposed.window.to are timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of those services, a span of probes made at different times. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ${SHODAN_OWNERSHIP}`;
1838
+ const RADAR_MCP_FIELDS = `mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them; hostnames whose owner opted out of scanning are not checked again while the opt-out stands, so their last verdict is left out too, and mcp_servers.withheld_opted_out counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure.`;
1804
1839
  // exposure_radar's envelope. Every radar answered, but no stats answer says when the services
1805
1840
  // or records it counts were observed: each count covers everything that radar still holds on
1806
1841
  // record, and the answer carries only generated_at (when the API computed the totals) and
@@ -1816,13 +1851,15 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
1816
1851
  // times over the counted rows, a span of checks made at different times, not one observation.
1817
1852
  // Taking window.to as measured_at would date every count by its newest verdict, the reading
1818
1853
  // #2335 and #2439 refuse for last_run_at and last_seen; window.from would date each one by its
1819
- // oldest. And the envelope is one for five radars, four of which carry no time of observation at
1820
- // all, so a measured state would date their numbers too (#2313 done-means 5). So the state stays
1854
+ // oldest. The other four radars date what they count the same way, at most by such a window
1855
+ // (#2438; kev_exposure's from Shodan's banner timestamps, since core-backend stored them), and the
1856
+ // envelope is one for all five (#2313 done-means 5); #2438's decision of 2026-10-04 keeps a window
1857
+ // out of measured_at for every one of them. So the state stays
1821
1858
  // not_assessed with measured_at null, the window is relayed in data and labelled by the note,
1822
1859
  // and the envelope's own note names it.
1823
1860
  const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev_exposure, Shodan banners whose version maps to a CISA-KEV-listed CVE; exposed_databases, services found through Shodan (LeakIX when Shodan query credits run low) and confirmed by EchelonGraph's own identified check; leaked_credentials, credential-shaped strings in public GitHub push events; shadow_ai, services found through Certificate Transparency logs and Shodan and checked by EchelonGraph's identified probes; mcp_servers, the latest verdict on record per hostname named like an MCP server in EchelonGraph's own Certificate Transparency feed, from EchelonGraph's identified MCP probe (server/discover, and initialize only if that is refused; never tools/call), where protected means the endpoint's RFC 9728 protected-resource metadata validated.";
1824
1861
  const RADAR_NAMES = RADARS.map(([name]) => name);
1825
- const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure gives none, and exposed_databases, leaked_credentials, shadow_ai's confirmed_exposed and mcp_servers date what they count at most by a window (exposed_databases.window, leaked_credentials.window, shadow_ai.confirmed_exposed.window, mcp_servers.window), from the oldest observation among the rows counted to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
1862
+ const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure, exposed_databases, leaked_credentials, shadow_ai's confirmed_exposed and mcp_servers date what they count at most by a window (kev_exposure.window, exposed_databases.window, leaked_credentials.window, shadow_ai.confirmed_exposed.window, mcp_servers.window), from the oldest observation among the rows counted to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
1826
1863
  const RADAR_FRESHNESS_NOTE = "freshness gives each radar's last completed check where the API serves one (freshness.kev_exposure.last_run_at, freshness.exposed_databases.last_run_at, freshness.leaked_credentials.last_run_at, freshness.shadow_ai.last_run_at with freshness.shadow_ai.running, and freshness.mcp_servers.last_run_at with freshness.mcp_servers.enabled), and null where it does not.";
1827
1864
  async function exposureRadar() {
1828
1865
  const tool = "exposure_radar";
@@ -1946,6 +1983,39 @@ export const opt = (t) => t.nullable().optional();
1946
1983
  // tool-claims check folds only constant strings).
1947
1984
  const CPE_MATCH_DESCRIPTION = "The record's CPE match criteria as one flat list, every configuration's cpeMatch entries together: the AND/OR operator and negate of each configuration and node, versionStartExcluding and matchCriteriaId are not in it, so a CPE that is only a platform a product runs on reads as one more entry.";
1948
1985
  const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent it: each configuration and node with its operator (AND, OR) and negate, and each cpeMatch with criteria, vulnerable, versionStartIncluding, versionStartExcluding, versionEndIncluding, versionEndExcluding and matchCriteriaId. A cpeMatch with vulnerable false inside an AND configuration is the platform the vulnerable product runs on, not an affected product. Absent: EchelonGraph holds no stored NVD configuration for the CVE, which is not a finding that no product is affected.";
1986
+ // #2861: what patch_available and patch_evidence are, as core-backend serves them since 2d8bb9ce
1987
+ // (core-backend/internal/cve/patch_evidence.go states the rule). Through that commit
1988
+ // patch_available was EXISTS(a cve_patches row), fed only by Ubuntu and Red Hat, so 101,725 CVEs
1989
+ // read false while EchelonGraph held a package fixed_version or an NVD Patch-tagged reference for
1990
+ // them (CVE-2017-5651 among them). patch_available is now true exactly when the evidence set is
1991
+ // non-empty, and only ever set true. So false is "no fix evidence on record", never "no fix
1992
+ // exists", and an answer without patch_evidence (an API older than it) says nothing about why.
1993
+ // #2889 / #2890 (rule 2): release_reference (EchelonGraph's own reference enrichment found, through
1994
+ // the GitHub API, a published release holding the fix; its HTML heuristics, the CISA KEV inference
1995
+ // among them, are not evidence) and cna_patch_reference (the CNA's own CVE record tags a reference
1996
+ // "patch"; NVD's capitalised "Patch" stays nvd_patch_reference).
1997
+ // One constant string each (the site's tool-claims check folds only constant strings).
1998
+ const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "nvd_patch_reference", "cna_patch_reference"];
1999
+ const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range), release_reference (a reference that EchelonGraph confirmed through the GitHub API is a published release containing the fix, or a pull request or commit that a published release contains), nvd_patch_reference (an NVD reference tagged Patch) and cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record)";
2000
+ // #2842: what the descriptions say; the sources, and that patch_available once true is never set
2001
+ // back, are the outputSchema's (PATCH_AVAILABLE_FIELD, PATCH_EVIDENCE_FIELD).
2002
+ const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds evidence of a fix for the CVE, and patch_evidence names its sources, strongest first. patch_available false means no fix evidence is on record, which is not a finding that no fix exists.";
2003
+ const PATCH_AVAILABLE_FIELD = "true: EchelonGraph holds evidence of a fix for the CVE, its sources named in patch_evidence. false: no fix evidence is on record, which is not a finding that no fix exists. Once true it is never set back to false. An answer with no patch_evidence (an API older than that field) does not name the evidence, and a false there is not a finding that no fix exists either.";
2004
+ const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds for the CVE, strongest first: " +
2005
+ PATCH_EVIDENCE_SOURCES +
2006
+ ". Empty where none was found when EchelonGraph last computed it. Absent (an API older than the field): the answer does not name the evidence.";
2007
+ // #2839: CISA's own KEV text, stored by core-backend's KEV fetcher and served on the record only
2008
+ // while kev_listed. Relayed as CISA's, verbatim; the required action is CISA's directive to US
2009
+ // federal civilian agencies, not EchelonGraph's advice. Constant strings, like the others here.
2010
+ const KEV_TEXT_ABSENT = "Absent where the CVE is not CISA-KEV-listed, where CISA's entry has none, or where EchelonGraph has not stored it yet (an API older than the field serves none).";
2011
+ const KEV_REQUIRED_ACTION_FIELD = "CISA's requiredAction for the CVE, verbatim from its Known Exploited Vulnerabilities catalog: the action CISA requires of US federal civilian agencies under Binding Operational Directive 22-01, by kev_due_date. It is CISA's text, not EchelonGraph's advice. " +
2012
+ KEV_TEXT_ABSENT;
2013
+ const KEV_SHORT_DESCRIPTION_FIELD = "CISA's shortDescription of the vulnerability, verbatim from its Known Exploited Vulnerabilities catalog. " + KEV_TEXT_ABSENT;
2014
+ const KEV_NOTES_URLS_FIELD = "The https links in CISA's notes for the CVE, in CISA's order, de-duplicated, at most 10: the references CISA's catalog entry gives, such as the vendor's advisory or instructions and NVD's page. " +
2015
+ KEV_TEXT_ABSENT;
2016
+ // #2841: what get_cve's remediation is. One constant string (the site's tool-claims check folds
2017
+ // only constant strings).
2018
+ const REMEDIATION_SUMMARY_FIELD = "A capped summary (at most 1,500 characters of JSON) of how the CVE is fixed, as its sources state it: vendor_remediation_kinds (per kind, the vendor advisories listing one for this CVE, by the vendor's own category), vendor_advisories, vendors_not_parsed, kev_required_action (CISA's, verbatim), fixed_branches_count, fixed_versions (per package, fixed versions on record), fix_references (URLs tagged Patch or Mitigation), failed_sections, truncated, and see, the name of the tool that returns the whole, with each text. A kind missing from vendor_remediation_kinds is not a finding that no such remediation exists. Absent for a REJECTED CVE and from an API older than the field.";
1949
2019
  const CVERecord = z.looseObject({
1950
2020
  cve_id: opt(z.string()),
1951
2021
  description: opt(z.string()),
@@ -1957,7 +2027,7 @@ const CVERecord = z.looseObject({
1957
2027
  echelongraph_score: opt(z.number()).describe("EchelonGraph's 0-10 score for the CVE: a score only when score_assessed is true. With score_assessed false it is a placeholder, not a rating."),
1958
2028
  echelongraph_severity: opt(z.string()).describe("The severity band of echelongraph_score: a rating only when score_assessed is true. With score_assessed false it is a placeholder (NONE)."),
1959
2029
  echelongraph_risk: opt(z.number()).describe("EchelonGraph's 0-100 risk priority, fused from the score, exploitation and automatability: a rating only when score_assessed is true. With score_assessed false it is a placeholder."),
1960
- score_confidence: opt(z.string()),
2030
+ score_confidence: opt(z.string()).describe("How confident EchelonGraph is in its score: NONE when score_assessed is false."),
1961
2031
  score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE. true: echelongraph_score, echelongraph_severity and echelongraph_risk are its score. false: the CVE is NOT YET SCORED, not scored 0, and any of those three it carries is a placeholder, not a rating; the API may leave them out. Absent (an API older than the field): the answer does not say whether the CVE was scored."),
1962
2032
  score_unassessed_reason: opt(z.string()).describe("Why score_assessed is false: no_signal, no source has yet published severity data EchelonGraph can score; or rejected, the record was withdrawn by its numbering authority and is never scored. Absent when the CVE is scored."),
1963
2033
  epss_score: opt(z.number()),
@@ -1965,19 +2035,33 @@ const CVERecord = z.looseObject({
1965
2035
  kev_listed: opt(z.boolean()),
1966
2036
  kev_ransomware: opt(z.boolean()),
1967
2037
  kev_added_date: opt(z.string()),
2038
+ kev_due_date: opt(z.string()).describe("CISA's dueDate: when US federal civilian agencies must have applied kev_required_action. Absent where the CVE is not CISA-KEV-listed."),
2039
+ kev_required_action: opt(z.string()).describe(KEV_REQUIRED_ACTION_FIELD),
2040
+ kev_short_description: opt(z.string()).describe(KEV_SHORT_DESCRIPTION_FIELD),
2041
+ kev_notes_urls: opt(z.array(z.string())).describe(KEV_NOTES_URLS_FIELD),
1968
2042
  ghsa_id: opt(z.string()),
2043
+ // #2861: fix evidence. A label the API adds later is admitted, never a failed call.
2044
+ patch_available: opt(z.boolean()).describe(PATCH_AVAILABLE_FIELD),
2045
+ patch_evidence: opt(z.array(z.union([z.enum(PATCH_EVIDENCE_LABELS), z.string()]))).describe(PATCH_EVIDENCE_FIELD),
1969
2046
  references: z.unknown().optional(),
1970
2047
  // #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
1971
2048
  cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
1972
2049
  cpe_configurations: opt(z.array(z.unknown())).describe(CPE_CONFIGURATIONS_DESCRIPTION),
2050
+ // #2841: a capped summary of how the CVE is fixed; cve_remediation returns the whole.
2051
+ remediation: opt(z.looseObject({})).describe(REMEDIATION_SUMMARY_FIELD),
1973
2052
  published: opt(z.string()),
1974
2053
  modified: opt(z.string()),
1975
2054
  updated_at: opt(z.string()),
1976
2055
  });
2056
+ // #2842: what SUMMARY_NVD_DESCRIPTION (below) says in full: the outputSchema's description of summary.
2057
+ const SUMMARY_NVD_FIELDS = "summary.nvd_critical, summary.nvd_high, summary.nvd_medium, summary.nvd_low and summary.nvd_none count the same active CVEs as summary.total by NVD's CVSS severity label (v3.x, else v4.0; before NVD's record arrives, or where it gives none, a pre-NVD label from the CVE.org record or a GitHub advisory can stand in): provenance, never EchelonGraph's severity band. summary.nvd_none counts the active CVEs with no Critical, High, Medium or Low label there, CVEs NVD never labelled under CVSS v3 among them, and many of those carry an NVD CVSS v2 score instead: it is neither a count of CVEs rated None nor the count of CVEs with no severity, which is summary.none. Whenever summary.nvd_none is above zero the note says what it counts, with its count.";
1977
2058
  // #2647: what cve_summary says the poller block is (summaryPollerNote). Like the #2641 strings
1978
2059
  // beside SUMMARY_NONE_DESCRIPTION, it uses none of #2610's six words ("any", "not", "rating",
1979
2060
  // "scored", "source", "yet"), #2771's sentence on a field of another JSON type among them. Declared
1980
2061
  // here, before the outputSchema that describes poller with it.
2062
+ // #2842: what cve_summary's description says of poller; SUMMARY_POLLER_DESCRIPTION, the
2063
+ // outputSchema's, names each counter and says what is left out.
2064
+ const SUMMARY_POLLER_HEAD = "poller holds the in-memory counters of the NVD poller of the one API instance that answered, counted since that instance last started and zeroed on every restart: they describe that instance, never the feed's size, intake, reliability or freshness. Whenever the answer carries poller the note says so.";
1981
2065
  const SUMMARY_POLLER_DESCRIPTION = "poller holds the in-memory counters of the NVD poller of the one API instance that answered (cves_ingested, cves_skipped, http_retries, poll_count, poll_errors, last_poll_at, last_poll_dur_ms, interval), counted since that instance last started and zeroed on every restart: they describe that instance, never the feed's size, intake, reliability or freshness. Whenever the answer carries poller the note says so. A poller field the answer sends in a JSON type other than the one described here is left out of data and named in the note, and a poller that is neither a JSON object nor null is left out whole: summary is relayed either way.";
1982
2066
  // One of the NVD histogram's four labelled buckets (#2641).
1983
2067
  const nvdBand = (label) => opt(z.number()).describe(`Of the active CVEs total counts, those whose NVD CVSS severity label is ${label}: NVD's label, as provenance, never EchelonGraph's severity band.`);
@@ -2003,7 +2087,8 @@ const CVE_SUMMARY_OUTPUT = envelopeSchema({
2003
2087
  rejected: opt(z.number()).describe("The CVE records rejected (withdrawn) by their numbering authority, which total and every other count here leave out: withdrawn records, never vulnerabilities."),
2004
2088
  last_updated: opt(z.string()),
2005
2089
  })
2006
- .optional(),
2090
+ .optional()
2091
+ .describe(SUMMARY_NVD_FIELDS),
2007
2092
  // #2647: one instance's counters, never the feed's (see summaryPollerNote).
2008
2093
  // #2771: each field's JSON type is SUMMARY_POLLER_KINDS', which summaryPollerShaped enforces
2009
2094
  // before this check, so a field of another type is left out, never a failed call.
@@ -2029,9 +2114,9 @@ const SEARCH_CVES_OUTPUT = envelopeSchema({
2029
2114
  data: z.looseObject({
2030
2115
  cves: z.array(CVERecord).optional(),
2031
2116
  total: opt(z.number()),
2032
- total_counted: opt(z.boolean()),
2033
- total_is_lower_bound: opt(z.boolean()),
2034
- search_relaxed: opt(z.boolean()),
2117
+ total_counted: opt(z.boolean()).describe("false: the matches were not counted, so total is not a count."),
2118
+ total_is_lower_bound: opt(z.boolean()).describe("true: there are at least total matches."),
2119
+ search_relaxed: opt(z.boolean()).describe("true: a search phrase was relaxed to all of its words."),
2035
2120
  limit: opt(z.number()),
2036
2121
  offset: opt(z.number()),
2037
2122
  }),
@@ -2040,7 +2125,7 @@ const SEARCH_CVES_OUTPUT = envelopeSchema({
2040
2125
  total_counted: z.boolean().nullable(),
2041
2126
  total_is_lower_bound: z.boolean().nullable(),
2042
2127
  search_relaxed: z.boolean().nullable(),
2043
- returned: z.number().nullable(),
2128
+ returned: z.number().nullable().describe("The rows in this page."),
2044
2129
  limit: z.number().nullable(),
2045
2130
  offset: z.number().nullable(),
2046
2131
  }),
@@ -2060,7 +2145,7 @@ const CVE_EXPOSURE_OUTPUT = envelopeSchema({
2060
2145
  ransomware: opt(z.boolean()),
2061
2146
  top_countries: opt(CountryRows),
2062
2147
  top_products: opt(z.array(z.looseObject({ product: opt(z.string()), hosts: opt(z.number()) }))),
2063
- last_seen: opt(z.string()),
2148
+ last_seen: opt(z.string()).describe(CVE_EXPOSURE_LAST_SEEN),
2064
2149
  method: opt(z.string()),
2065
2150
  generated_at: opt(z.string()),
2066
2151
  }),
@@ -2132,12 +2217,12 @@ const MCPServersData = z.strictObject({
2132
2217
  });
2133
2218
  const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
2134
2219
  data: z.strictObject({
2135
- kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }),
2136
- exposed_databases: zRadar(EXPOSED_DB_SPEC),
2137
- leaked_credentials: zRadar(LEAKED_CREDS_SPEC),
2138
- shadow_ai: ShadowAIData,
2139
- mcp_servers: MCPServersData,
2140
- }),
2220
+ kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }).describe(RADAR_KEV_FIELDS),
2221
+ exposed_databases: zRadar(EXPOSED_DB_SPEC).describe(RADAR_DB_FIELDS),
2222
+ leaked_credentials: zRadar(LEAKED_CREDS_SPEC).describe(RADAR_LEAKED_FIELDS),
2223
+ shadow_ai: ShadowAIData.describe(RADAR_SHADOW_AI_FIELDS),
2224
+ mcp_servers: MCPServersData.describe(RADAR_MCP_FIELDS),
2225
+ }).describe(RADAR_LAST_RUN_FIELDS),
2141
2226
  coverage: z.strictObject({
2142
2227
  radars: z.array(z.string()).describe("The radars this tool reads."),
2143
2228
  answered: z.array(z.string()).describe("The radars that answered. On a failure their answers are withheld."),
@@ -2166,34 +2251,35 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
2166
2251
  // ── Server instructions (#2311) ──
2167
2252
  // No sentence here names a re-probe interval; any that does must say it applies to hosts we do
2168
2253
  // not own (the package's tests hold every shipped text to that).
2254
+ // #2842: within the client cut (requiredText.ts), in this order: what the server is; Shodan's
2255
+ // attribution and ownership; what state says; the units of an exposure count; freshness. How a
2256
+ // result's text blocks repeat its structuredContent is in the README ('## Structured results') and
2257
+ // the methodology resource, and each description says what its own result carries.
2169
2258
  const INSTRUCTIONS = [
2170
2259
  "EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
2171
- "Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
2172
- "Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
2173
- "Its last text block is that structuredContent as JSON, less what an earlier text block already gives verbatim: data, which is a success's first text block; the sentences of the text block just before it (the note, or a failure's message), with which structuredContent's notes end; and method, where that block quotes it.",
2174
- "Past 30,000 characters of JSON, that first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); structuredContent's data always holds it whole.",
2175
- "state is measured, not_assessed, failed or invalid_input.",
2260
+ "Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted exposure totals.",
2261
+ `${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
2262
+ "exposure_radar's mcp_servers counts use no Shodan data: their hostnames come from EchelonGraph's own Certificate Transparency feed.",
2263
+ "Every result carries state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
2176
2264
  "not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
2177
- "It can still relay a count, as what the source holds on record, undated, and its notes say what each count is: cve_exposure's exposed_hosts when its exposure_state is exposed, and exposure_radar's labelled totals, are such counts.",
2265
+ "It can still relay a count, as what the source holds on record, undated, and its notes say what each count is.",
2178
2266
  "A zero is not a finding of no exposure where the answer holds no measurement for that CVE, for example a CVE outside the KEV-exposure radar's tracked set (exposure_state not_assessed).",
2179
2267
  "failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
2180
- "measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
2268
+ "measured_at is when the underlying observation was made, or null.",
2269
+ "Exposure numbers are aggregate: counts of distinct ip:port services on EchelonGraph's record (a machine answering on two ports counts twice), not machines, and not an internet-wide census; exposure_radar's mcp_servers counts hostnames instead, each by its latest verdict on record.",
2181
2270
  "freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
2182
- "That is not the time of every record the radar's numbers count, which cover everything still on record, and it is null when the API does not say.",
2183
- "Every figure is as fresh as the schedule that refreshes it.",
2184
- "Exposure numbers are aggregate: counts of distinct ip:port services on EchelonGraph's record (a machine answering on two ports counts twice), not machines, and not an internet-wide census.",
2185
- "exposure_radar's mcp_servers counts hostnames instead, each by its latest verdict on record.",
2186
- `${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
2187
- "exposure_radar's mcp_servers counts use no Shodan data: their hostnames come from EchelonGraph's own Certificate Transparency feed.",
2271
+ "That is not the time of every record the radar's numbers count, and it is null when the API does not say.",
2188
2272
  ].join(" ");
2189
2273
  export const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
2190
2274
  const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
2191
2275
  // What each description says about its structured result, naming only fields its schema holds.
2192
- const FEED_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) 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.";
2276
+ // #2842: the generic rule for the result's last text block is in the README and the methodology
2277
+ // resource, not in each description.
2278
+ const FEED_ENVELOPE = "Its freshness is null: the feed serves no poll-completion time.";
2193
2279
  // #2771: cve_summary's own, since its data is the API's JSON less each poller field (or the whole
2194
2280
  // poller) summaryPollerShaped leaves out. Like SUMMARY_POLLER_DESCRIPTION it uses none of #2610's
2195
2281
  // six words ("any", "not", "rating", "scored", "source", "yet").
2196
- const CVE_SUMMARY_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON less each poller field (or the whole poller) the note names as left out; 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.";
2282
+ const CVE_SUMMARY_ENVELOPE = "Its structured result's state is measured, with freshness null (the feed serves no poll-completion time) and data equal to the API's JSON less each poller field (or the whole poller) the note names as left out.";
2197
2283
  // #2610: what cve_summary says summary.none is (summaryNoneNote), one constant string for the same
2198
2284
  // reason as SCORE_ASSESSED_DESCRIPTION below.
2199
2285
  const SUMMARY_NONE_DESCRIPTION = "summary.none is not a severity rating of None: it counts the active CVEs with no severity band from any source, that is, CVEs not yet scored, and the answer may carry the same count again as summary.unscored. Whenever summary.none is above zero the note says so, and names those CVEs not yet scored, not CVEs rated None.";
@@ -2202,11 +2288,11 @@ const SUMMARY_NONE_DESCRIPTION = "summary.none is not a severity rating of None:
2202
2288
  // "rating", "scored", "source" or "yet": the site's #2610 control (marketing-site
2203
2289
  // lib/mcpToolClaims.test.ts) holds that exactly those words of the /pulse/mcp cve_summary row are
2204
2290
  // backed by SUMMARY_NONE_DESCRIPTION alone, and fails if another sentence backs one.
2205
- const SUMMARY_NVD_DESCRIPTION = "summary.nvd_critical, summary.nvd_high, summary.nvd_medium, summary.nvd_low and summary.nvd_none count the same active CVEs as summary.total by NVD's CVSS severity label (v3.x, else v4.0; before NVD's record arrives, or where it gives none, a pre-NVD label from the CVE.org record or a GitHub advisory can stand in): provenance, never EchelonGraph's severity band. summary.nvd_none counts the active CVEs with no Critical, High, Medium or Low label there, CVEs NVD never labelled under CVSS v3 among them, and many of those carry an NVD CVSS v2 score instead: it is neither a count of CVEs rated None nor the count of CVEs with no severity, which is summary.none. Whenever summary.nvd_none is above zero the note says what it counts, with its count.";
2291
+ const SUMMARY_NVD_DESCRIPTION = "summary.nvd_critical, summary.nvd_high, summary.nvd_medium, summary.nvd_low and summary.nvd_none count the same active CVEs as summary.total by NVD's CVSS severity label: provenance, never EchelonGraph's severity band. summary.nvd_none is neither a count of CVEs rated None nor the count of CVEs with no severity, which is summary.none.";
2206
2292
  const SUMMARY_REJECTED_DESCRIPTION = "summary.rejected counts the CVE records rejected (withdrawn) by their numbering authority, which summary.total and the other counts above leave out: they are withdrawn records, none of them a vulnerability.";
2207
2293
  // #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
2208
2294
  // site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
2209
- const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; the API may leave them out instead, score_confidence is NONE, and score_unassessed_reason says why (a rejected record, withdrawn by its numbering authority, is never scored, and the note says NOT SCORED). The note labels each such CVE NOT YET SCORED, which is not a score of 0. An answer with no score_assessed (an API older than that field) does not say whether the CVE was scored, the note says so, and a 0 there is not a rating either.";
2295
+ const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; score_unassessed_reason says why, and the note labels each such CVE NOT YET SCORED. An answer with no score_assessed (an API older than that field) does not say whether the CVE was scored, the note says so, and a 0 there is not a rating either.";
2210
2296
  // The shared helpers a tool in src/tools/ is handed (#2718), so its file holds its own logic.
2211
2297
  const TOOL_KIT = { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS, cveIdArg: CVE_ID_ARG, instant: Instant };
2212
2298
  // One server, built per connection by serveStdio for whichever era the client opens with, and
@@ -2216,13 +2302,13 @@ export function createServer() {
2216
2302
  const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
2217
2303
  server.registerTool("cve_summary", {
2218
2304
  title: "CVE feed summary",
2219
- description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${CVE_SUMMARY_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
2305
+ description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_HEAD} The feed is polled from its sources on a schedule, so this is the state as of that update. ${CVE_SUMMARY_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
2220
2306
  outputSchema: CVE_SUMMARY_OUTPUT,
2221
2307
  annotations: ANNOTATIONS,
2222
2308
  }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
2223
2309
  server.registerTool("search_cves", {
2224
2310
  title: "Search CVEs",
2225
- description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort; page with limit and offset. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page. ${TEXT_BUDGET_DESCRIPTION} Cut, each row keeps at least the fields named above and the first 200 characters of its description (100 on a page too long for that), or rows are left out and the note gives the offset to call next.`,
2311
+ description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort; page with limit and offset. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: total is not a count), total_is_lower_bound (true: at least total), search_relaxed, limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each row keeps at least the fields named above and the first 200 characters of its description (100 on a page too long for that), or rows are left out and the note gives the offset to call next.`,
2226
2312
  inputSchema: z.object({
2227
2313
  search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
2228
2314
  severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
@@ -2237,21 +2323,21 @@ export function createServer() {
2237
2323
  }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
2238
2324
  server.registerTool("get_cve", {
2239
2325
  title: "CVE detail",
2240
- description: `One CVE's record: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, cpe_match (the CPE criteria as one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator, negate, versionStartExcluding and matchCriteriaId; absent where EchelonGraph has stored none, which is not a finding that no product is affected), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
2326
+ description: `One CVE's record: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity, echelongraph_score and echelongraph_severity with score_confidence and score_assessed, epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, kev_due_date, kev_ransomware) and, for a KEV-listed CVE, CISA's own text relayed verbatim (kev_required_action, not EchelonGraph's advice; kev_short_description; kev_notes_urls), ghsa_id, patch_available and patch_evidence, references, cpe_match (one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator; absent where EchelonGraph has stored none, which is not a finding that no product is affected), remediation (a capped summary of how it is fixed), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
2241
2327
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2242
2328
  outputSchema: GET_CVE_OUTPUT,
2243
2329
  annotations: ANNOTATIONS,
2244
2330
  }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
2245
2331
  server.registerTool("cve_exposure", {
2246
2332
  title: "Internet exposure for one CVE",
2247
- description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …"). ${TEXT_BUDGET_DESCRIPTION}`,
2333
+ description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null, since the answer dates no observation; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null; data is the API's JSON. ${TEXT_BUDGET_DESCRIPTION}`,
2248
2334
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2249
2335
  outputSchema: CVE_EXPOSURE_OUTPUT,
2250
2336
  annotations: ANNOTATIONS,
2251
2337
  }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
2252
2338
  server.registerTool("exposure_radar", {
2253
2339
  title: "Exposure radar totals",
2254
- description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but none gives one time at which what it counts was observed (mcp_servers dates its verdicts at most by a window, mcp_servers.window), so no count is presented as a dated measurement; each count is still relayed, labelled, as what that radar holds on record. freshness gives each radar's last_run_at (and running for shadow_ai, enabled for mcp_servers) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data (the first text block) and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`,
2340
+ description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null, since no radar dates what it counts; each count is still relayed as what that radar holds on record. freshness gives each radar's last_run_at where the API serves one; coverage names the radars that answered. ${TEXT_BUDGET_DESCRIPTION}`,
2255
2341
  outputSchema: EXPOSURE_RADAR_OUTPUT,
2256
2342
  annotations: ANNOTATIONS,
2257
2343
  }, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
@@ -2263,7 +2349,12 @@ export function createServer() {
2263
2349
  // (#2775): on the hosted endpoint, a call keeps its request's admission and large-body slot
2264
2350
  // until it has stopped, not only until its answer, or its client, has gone.
2265
2351
  registerCheckSbom(server, { api, failed, describeFailure, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS, hold: holdRequest });
2352
+ // #2835: a project's lockfiles read into purls here and checked through the same batch loop
2353
+ // (tools/scan_manifest.ts, tools/match_batch.ts), held on the hosted endpoint the same way.
2354
+ registerScanManifest(server, { api, failed, describeFailure, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS, hold: holdRequest });
2266
2355
  registerCveIntel(server);
2356
+ // #2841: how one CVE is fixed, as its sources state it (tools/cve_remediation.ts).
2357
+ registerCveRemediation(server);
2267
2358
  registerGetCwe(server);
2268
2359
  // #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
2269
2360
  registerVendorAdvisoryTools(server, { api, failed, describeFailure, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });