echelongraph-mcp 2.6.7 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/README.md +64 -30
- package/dist/http.js +2 -2
- package/dist/httpPolicy.js +13 -9
- package/dist/index.js +148 -57
- package/dist/prompts.js +102 -9
- package/dist/requiredText.js +118 -0
- package/dist/resources.js +2 -0
- package/dist/tools/check_affected.js +41 -18
- package/dist/tools/check_sbom.js +37 -374
- package/dist/tools/cve_intel.js +1 -1
- package/dist/tools/cve_remediation.js +257 -0
- package/dist/tools/epss_history.js +100 -6
- package/dist/tools/kev_recent.js +4 -4
- package/dist/tools/manifests/common.js +48 -0
- package/dist/tools/manifests/gomod.js +168 -0
- package/dist/tools/manifests/index.js +80 -0
- package/dist/tools/manifests/lockfiles.js +176 -0
- package/dist/tools/manifests/npmlock.js +107 -0
- package/dist/tools/manifests/requirements.js +125 -0
- package/dist/tools/manifests/toml.js +299 -0
- package/dist/tools/match_batch.js +432 -0
- package/dist/tools/scan_manifest.js +257 -0
- package/dist/tools/vendor_advisories.js +128 -20
- package/package.json +3 -1
- package/server.json +2 -2
package/dist/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
|
|
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
|
-
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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.
|
|
1820
|
-
//
|
|
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
|
|
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
|
|
2172
|
-
|
|
2173
|
-
"
|
|
2174
|
-
"
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
|
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} ${
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 });
|