echelongraph-mcp 2.3.4 → 2.5.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.
@@ -0,0 +1,153 @@
1
+ // MCP resources (#2722): context a client can attach.
2
+ //
3
+ // echelongraph://methodology static text: what each evidence-envelope field means, the state
4
+ // values, and how each tool measures (from the README's envelope
5
+ // docs). No API request.
6
+ // echelongraph://sources the feeds and what the API itself reports about their polling,
7
+ // read from the API at request time and never hard-coded: the CVE
8
+ // summary's poller block (/api/v1/public/cves/summary) and the KEV
9
+ // answer's catalog block and method (/api/v1/public/kev/recent).
10
+ // What no endpoint reports is named as not reported.
11
+ // cve://{cve_id} get_cve's structured result for one CVE, as JSON: the envelope
12
+ // (state, measured_at, method, notes) with data, the API's record.
13
+ //
14
+ // A resource read either answers or is a JSON-RPC error; it never answers a failure as data that
15
+ // reads like a record. sources is the exception by design: each feed it reads carries its own
16
+ // state, and a feed that could not be read says so (state failed), never an empty block.
17
+ import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
18
+ export const METHODOLOGY_URI = "echelongraph://methodology";
19
+ export const SOURCES_URI = "echelongraph://sources";
20
+ export const CVE_TEMPLATE = "cve://{cve_id}";
21
+ export const METHODOLOGY = `# How EchelonGraph MCP results are measured
22
+
23
+ Every tool result carries an evidence envelope in its structuredContent, and repeats it in its last text block (less what an earlier text block already carries verbatim), so a client that passes text blocks alone still sees it.
24
+
25
+ ## Envelope fields
26
+
27
+ | Field | What it holds |
28
+ |---|---|
29
+ | state | measured, not_assessed, failed or invalid_input (below). |
30
+ | measured_at | When the underlying observation was made, as the API states it. null when the answer does not say or holds no observation; never the Go zero time. |
31
+ | method | How the numbers were produced. null on a failure. |
32
+ | coverage | What the answer covers, where the tool can say: for example in_scope for cve_exposure, the list's account of its own count for search_cves, assessed and not_assessed_reason first for check_affected. |
33
+ | freshness | The producing radar's or fetcher's last completed check, where the API serves one; null where it serves none. |
34
+ | notes | The caveats, one sentence each. |
35
+ | data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection). |
36
+ | error | On a failure: kind, path, status and message. |
37
+
38
+ ## state
39
+
40
+ | state | What it means |
41
+ |---|---|
42
+ | measured | A measurement of what was asked. An exposure count is measured when the answer says when what it counts was observed and how it was produced; no exposure answer says when today, so neither exposure tool answers measured. |
43
+ | not_assessed | The answer holds no dated measurement of what was asked, so no count in it is presented as one. It can still relay a count, as what the source holds on record, undated; the notes say what each count is. Never clean. |
44
+ | failed | The lookup did not complete: unreachable host, non-2xx, timeout, or a body that is not a JSON object. Not a finding, never zero. |
45
+ | invalid_input | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
46
+
47
+ A count of 0 is a finding of none when the answer measured it. Under assessed: false, or state not_assessed, it is not: report it as not assessed.
48
+
49
+ ## How each tool measures
50
+
51
+ - cve_summary: measured; measured_at is summary.last_updated, the newest modification time among the active CVE records it counts. freshness is null: the feed serves no poll-completion time for the feed as a whole.
52
+ - search_cves: measured, measured_at null (each record carries its own times); coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset.
53
+ - get_cve: measured; measured_at is the record's updated_at, when EchelonGraph last wrote it. score_assessed false means not yet scored, not a score of 0.
54
+ - cve_exposure: not_assessed with measured_at null, every answer: its last_seen is when EchelonGraph last wrote or refreshed a service's row, not an observation time. exposure_state (exposed, measured_zero, not_assessed, tracking_unknown) says what the count is; exposed_hosts counts distinct ip:port services from Shodan data, not machines, and is not an internet-wide census.
55
+ - exposure_radar: not_assessed with measured_at null: no radar's answer gives one time at which what it counts was observed. freshness gives each radar's last_run_at where the API serves one.
56
+ - kev_recent: measured; measured_at is EchelonGraph's last successful fetch of CISA's KEV feed; freshness carries that fetch time, catalog_version and date_released.
57
+ - epss_history: measured_at is current.epss_updated_at, when EchelonGraph last wrote a changed EPSS value; the series is change-only, so a missing point is not recorded, not unchanged.
58
+ - check_affected: measured when the answer says assessed true; not_assessed when it says false or does not say. measured_at and freshness are null.
59
+ - check_sbom: measured when at least one component got a verdict, else not_assessed; measured_at null. not_affected is the one clean verdict; undetermined and not_assessed are not clean.
60
+ - cve_intel: measured_at null: each row carries its own first_seen_at or enriched_at. An empty exploits list is not evidence that no public exploit exists.
61
+ - get_cwe: measured_at null: each row carries its own published date. A total of 0 says no CVE in EchelonGraph's feed is classified under that CWE.
62
+ - vendor_advisories_for_cve, search_vendor_advisories: measured_at null (each row carries vendor_published_at and our_first_seen_at). get_vendor_advisory: measured_at is our_first_seen_at, when EchelonGraph first recorded the advisory.
63
+
64
+ Every figure is as fresh as the schedule that refreshes it. The resource ${SOURCES_URI} reports what the API itself says about its feeds' polling.
65
+ `;
66
+ const SUMMARY_PATH = "/api/v1/public/cves/summary";
67
+ const KEV_PATH = "/api/v1/public/kev/recent";
68
+ const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined);
69
+ const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o !== undefined && o[k] !== undefined).map((k) => [k, o[k]]));
70
+ const failure = (r) => ({ state: "failed", error: { kind: r.kind, path: r.path, status: r.status ?? null, message: r.detail } });
71
+ // What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, of
72
+ // the server instance that answered (core-backend cve/poller.go Stats, in memory).
73
+ const POLLER_FIELDS = ["interval", "last_poll_at", "poll_count", "poll_errors"];
74
+ const CATALOG_FIELDS = ["source", "feed_url", "last_successful_fetch_at", "catalog_version", "date_released", "catalog_count"];
75
+ export async function readSources(kit) {
76
+ const [summary, kev] = await Promise.all([kit.api(SUMMARY_PATH), kit.api(KEV_PATH, { headers: { "X-EG-Limit": "1" } })]);
77
+ const nvd = { feed: "NVD CVE records (EchelonGraph's CVE feed poller)", endpoint: SUMMARY_PATH };
78
+ if (!summary.ok)
79
+ Object.assign(nvd, failure(summary));
80
+ else {
81
+ const poller = obj(obj(summary.data)?.poller);
82
+ if (poller === undefined) {
83
+ Object.assign(nvd, { state: "not_reported", reported: null, notes: ["The answer carries no poller block, so it reports neither a poll interval nor a last poll time."] });
84
+ }
85
+ else {
86
+ Object.assign(nvd, {
87
+ state: "reported",
88
+ reported: pick(poller, POLLER_FIELDS),
89
+ notes: [
90
+ "poller is the NVD poller of the server instance that answered, held in its memory: interval is its configured interval (each cycle adds a start-up jitter), and last_poll_at is that instance's last poll, not a fleet-wide time.",
91
+ ],
92
+ });
93
+ }
94
+ }
95
+ const cisa = { feed: "CISA Known Exploited Vulnerabilities catalog", endpoint: KEV_PATH };
96
+ if (!kev.ok)
97
+ Object.assign(cisa, failure(kev));
98
+ else {
99
+ const d = obj(kev.data);
100
+ const catalog = obj(d?.catalog);
101
+ const method = typeof d?.method === "string" ? d.method : null;
102
+ Object.assign(cisa, {
103
+ state: catalog === undefined && method === null ? "not_reported" : "reported",
104
+ reported: { catalog: catalog === undefined ? null : pick(catalog, CATALOG_FIELDS), method },
105
+ notes: [
106
+ "method is the API's own sentence about how it polls CISA's feed, quoted as sent; catalog.last_successful_fetch_at is when CISA last answered that poll.",
107
+ ],
108
+ });
109
+ }
110
+ return {
111
+ uri: SOURCES_URI,
112
+ api_base: kit.shownBase,
113
+ read_at: new Date().toISOString(),
114
+ sources: [nvd, cisa],
115
+ not_reported: [
116
+ "No public endpoint this server reads reports a poll cadence or a last poll time for the MITRE-CNA pre-NVD records, EPSS, GitHub GHSA, the OSV.dev advisory records, the vendor-advisory feeds, or the exposure radars' sources; this resource states none for them.",
117
+ "exposure_radar's freshness gives each radar's last_run_at, its last completed check, where the API serves one.",
118
+ ],
119
+ };
120
+ }
121
+ const json = (uri, v) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(v, null, 2) }] });
122
+ export function registerResources(server, kit) {
123
+ server.registerResource("methodology", METHODOLOGY_URI, {
124
+ title: "How results are measured",
125
+ description: "What each evidence-envelope field (state, measured_at, method, coverage, freshness, notes) means, the state values, and how each tool measures.",
126
+ mimeType: "text/markdown",
127
+ }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: METHODOLOGY }] }));
128
+ server.registerResource("sources", SOURCES_URI, {
129
+ title: "Feed sources and polling",
130
+ description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval and last poll (of the instance that answered) and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
131
+ mimeType: "application/json",
132
+ }, async (uri) => json(uri.href, await readSources(kit)));
133
+ server.registerResource("cve", new ResourceTemplate(CVE_TEMPLATE, { list: undefined }), {
134
+ title: "CVE record",
135
+ description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. cve_id is a CVE ID such as CVE-2024-3400.",
136
+ mimeType: "application/json",
137
+ }, async (uri, vars) => {
138
+ const raw = Array.isArray(vars.cve_id) ? vars.cve_id[0] : vars.cve_id;
139
+ const id = decodeURIComponent(String(raw ?? "")).trim();
140
+ if (!kit.cveId.test(id)) {
141
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, `cve:// takes a CVE ID such as CVE-2024-3400 (${id.length} characters given)`, { uri: uri.href, reason: "invalid_cve_id" });
142
+ }
143
+ const r = await kit.getCve(id.toUpperCase());
144
+ if (r.isError) {
145
+ const msg = r.content[0]?.text ?? "get_cve failed";
146
+ const err = obj(r.structuredContent.error);
147
+ if (err?.status === 404)
148
+ throw new ResourceNotFoundError(uri.href);
149
+ throw new ProtocolError(ProtocolErrorCode.InternalError, msg, { uri: uri.href, state: r.structuredContent.state ?? null, error: err ?? null });
150
+ }
151
+ return json(uri.href, r.structuredContent);
152
+ });
153
+ }
@@ -0,0 +1,65 @@
1
+ // Which entrypoint is running, and — for the hosted HTTP entrypoint only — the end client a
2
+ // request is being answered for (#2316).
3
+ //
4
+ // One tool module (index.ts) serves two entrypoints: the npm package's stdio bin (index.ts
5
+ // itself) and the hosted remote endpoint at mcp.echelongraph.io (http.ts). http.ts marks itself
6
+ // here BEFORE it loads index.ts, which is how index.ts knows not to start the stdio transport.
7
+ // The default is stdio: an entrypoint that forgets to mark itself gets the npm behaviour,
8
+ // which is the published package's, unchanged.
9
+ //
10
+ // The npm package sends nothing extra, ever: upstreamHeaders() is empty unless the HTTP
11
+ // entrypoint is running (the no-telemetry promise, #2304).
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ let httpEntrypoint = false;
14
+ /** Called by http.ts before it imports index.ts. */
15
+ export function markHttpEntrypoint() {
16
+ httpEntrypoint = true;
17
+ }
18
+ export function isHttpEntrypoint() {
19
+ return httpEntrypoint;
20
+ }
21
+ // The client a tool call is being answered for. http.ts runs each request inside
22
+ // withClient(), and the SDK dispatches the tool handler inside that same async context, so
23
+ // api() can read it without the tool module passing it through every function.
24
+ const clientStore = new AsyncLocalStorage();
25
+ export function withClient(clientIp, fn) {
26
+ return clientStore.run({ clientIp }, fn);
27
+ }
28
+ /** The header that carries the forward token, and the one that names the client (core-backend waf/mcpforward.go). */
29
+ export const FORWARD_TOKEN_HEADER = "X-EG-MCP-Forward";
30
+ export const CLIENT_IP_HEADER = "X-EG-Client-IP";
31
+ /** The token, trimmed: a Secret Manager value can carry a trailing newline. */
32
+ export function forwardToken() {
33
+ return (process.env.ECHELONGRAPH_FORWARD_TOKEN ?? "").trim();
34
+ }
35
+ // The hosts the token may be sent to: core-backend's public host, plus one exact hostname an
36
+ // operator (or a test's stub) names. Host-exact, never a suffix, for the reason marketing-site's
37
+ // internalApiHeaders gives: nothing look-alike may coax the token out, even if
38
+ // ECHELONGRAPH_API_BASE is ever pointed somewhere else.
39
+ function tokenMayGoTo(url) {
40
+ let host;
41
+ try {
42
+ host = new URL(url).hostname;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ const extra = (process.env.ECHELONGRAPH_FORWARD_HOST ?? "").trim();
48
+ return host === "app.echelongraph.io" || (extra !== "" && host === extra);
49
+ }
50
+ /**
51
+ * The extra request headers for one API call to `url`: on the HTTP entrypoint, with a token
52
+ * configured, a client known and the URL on the allowlist, the token and the client's address;
53
+ * otherwise nothing. Why the API believes them, and under what rule, is core-backend's
54
+ * waf.MCPForwardedClient: without them every remote user would be keyed on this service's
55
+ * one address and share one budget (#2212).
56
+ */
57
+ export function upstreamHeaders(url) {
58
+ if (!httpEntrypoint)
59
+ return {};
60
+ const token = forwardToken();
61
+ const clientIp = clientStore.getStore()?.clientIp;
62
+ if (!token || !clientIp || !tokenMayGoTo(url))
63
+ return {};
64
+ return { [FORWARD_TOKEN_HEADER]: token, [CLIENT_IP_HEADER]: clientIp };
65
+ }
@@ -0,0 +1,269 @@
1
+ // check_affected (#2716): "am I affected by product X at version Y?", answered by the same
2
+ // endpoint as echelongraph.io/am-i-affected, GET /api/v1/public/cves/match (core-backend
3
+ // internal/cve/handler.go MatchByProductPublic). Two lookup paths, chosen by the input:
4
+ //
5
+ // cpe {product, version}: the CPE matcher (matchByProductPublic). CVEs whose NVD CPE
6
+ // product field is this token and whose version range includes this version. It
7
+ // matches the product token across vendors; each match names the vendor NVD asserts
8
+ // (cpe_vendor) and whether that vendor was verified (vendor_unknown).
9
+ // registry {ecosystem, package, version}: the registry matcher (matchByPackage). The OSV
10
+ // advisory records EchelonGraph holds for this package, each decided against this
11
+ // version as affected, not affected, or undetermined.
12
+ //
13
+ // #1983: every typed value travels in a request header (X-EG-Product, X-EG-Version,
14
+ // X-EG-Ecosystem, X-EG-Package; handler.go MatchProductHeader..MatchPackageHeader), percent-
15
+ // encoded as the backend's publicMatchParam decodes it, and never in the URL: Cloud Run's request
16
+ // log and its trace spans record the URL and no request header. The backend answers a
17
+ // header-carried lookup with Cache-Control: private, no-store and varies on the four headers. The
18
+ // endpoint reads the CPE vendor from the query string only (handler.go matchByProductPublic), so
19
+ // this tool takes no vendor: sending one would put a typed value in the URL.
20
+ //
21
+ // The verdict is `assessed`, read BEFORE `count` (the endpoint's own contract): assessed false
22
+ // means the lookup did not evaluate this component, and count 0 then asserts nothing. So the
23
+ // envelope's state is measured only when the answer says assessed true; assessed false, or an
24
+ // answer without assessed, is not_assessed, and the note's first sentence after the head says so,
25
+ // with the reason. A 500/503 (advisory_lookup_failed) is a failure like any other non-2xx.
26
+ //
27
+ // The JSON is relayed verbatim as data (#1874): every match keeps kev_listed, ransomware,
28
+ // epss_score, effective_score, effective_severity and score_assessed as the API sent them.
29
+ import * as z from "zod";
30
+ const TOOL = "check_affected";
31
+ export const MATCH_PATH = "/api/v1/public/cves/match";
32
+ // handler.go, the four request headers the endpoint reads before the query string.
33
+ export const MATCH_HEADERS = { product: "X-EG-Product", version: "X-EG-Version", ecosystem: "X-EG-Ecosystem", package: "X-EG-Package" };
34
+ const CPE_METHOD = "EchelonGraph's CPE matcher over its CVE corpus, the matcher behind echelongraph.io/am-i-affected and EchelonGraph's scanner: CVEs whose NVD CPE match criteria name this product token and whose version range includes this version.";
35
+ const REGISTRY_METHOD = "EchelonGraph's registry matcher, the matcher behind echelongraph.io/am-i-affected for a registry package: the OSV advisory records EchelonGraph holds for this package in this ecosystem, each decided against this version as affected, not affected, or undetermined.";
36
+ const NO_TIME = "measured_at is null: a match answer carries no observation time, and each matched CVE is a record of the CVE feed, polled from its sources on a schedule.";
37
+ const NO_FRESHNESS = "freshness is null: the match answer carries no time at which the feed's pollers last completed.";
38
+ // Why an answer says assessed false (core-backend cve/cpematch.go, cve/pkgmatch.go). One sentence
39
+ // each, worded so that none reads as a clean result.
40
+ const REASONS = {
41
+ product_not_in_cpe_corpus: "no CVE in the CPE corpus has this token as its CPE product, so the component was not located, not cleared; the CPE product token can differ from a package name (spring_boot, not spring-boot), and a registry package can be looked up by ecosystem and package instead.",
42
+ package_not_cpe_nameable: "the CVEs the CPE corpus holds for this token describe it in a way the CPE lookup cannot decide for this asset (an npm package NVD records only for target_sw node.js is the common case), so a registry lookup by ecosystem and package is the one to use.",
43
+ candidate_load_pending: "the product's candidate CVEs were still loading when this request's time ran out (degraded), so nothing was compared; the same lookup can be retried shortly.",
44
+ candidate_window_truncated: "the product has more candidate CVEs than one lookup loads (candidates_capped), so the CVEs below the cut were never examined.",
45
+ package_not_in_advisory_corpus: "EchelonGraph holds no advisory record for this package in this ecosystem, so the package was not located, not cleared; the ecosystem and package name are worth checking.",
46
+ no_decidable_advisory: "EchelonGraph holds advisories for this package, but not one of them could be decided either way at this version (see undetermined).",
47
+ advisory_lookup_failed: "the advisory lookup failed.",
48
+ };
49
+ // The reasons this version describes, and any other string, which the note names as one it does not.
50
+ const REASON = z.union([z.enum(Object.keys(REASONS)), z.string()]).describe("Why assessed is false; empty or absent when it is true.");
51
+ const opt = (t) => t.nullable().optional();
52
+ const Match = z.looseObject({
53
+ cve_id: opt(z.string()),
54
+ description: opt(z.string()),
55
+ severity: opt(z.string()).describe("NVD's own CVSS v3 band, as provenance: it can be NONE for a CVE NVD rated only under CVSS v2. Rank and display on effective_severity."),
56
+ cvss_v3_score: opt(z.number()),
57
+ cvss_v2_score: opt(z.number()),
58
+ cvss_v4_score: opt(z.number()),
59
+ kev_listed: opt(z.boolean()).describe("Listed in CISA-KEV: known to be exploited."),
60
+ ransomware: opt(z.boolean()).describe("CISA-KEV records known use in ransomware campaigns."),
61
+ epss_score: opt(z.number()).describe("EPSS exploitation probability, 0 to 1."),
62
+ echelongraph_score: opt(z.number()).describe("EchelonGraph's 0-10 score: present only when score_assessed is true."),
63
+ echelongraph_severity: opt(z.string()),
64
+ effective_score: opt(z.number()).describe("The best real score held for the CVE (EchelonGraph's, else CVSS), the number the matches are ranked by."),
65
+ effective_severity: opt(z.string()).describe("The band of effective_score; absent or empty when the CVE has no band at all (unrated, not harmless)."),
66
+ score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE. false: NOT YET SCORED, and echelongraph_score is withheld."),
67
+ score_unassessed_reason: opt(z.string()),
68
+ vendor_severity: opt(z.string()),
69
+ matched_criteria: opt(z.string()),
70
+ match_method: opt(z.string()),
71
+ match_confidence: opt(z.number()),
72
+ cpe_vendor: opt(z.string()).describe("The vendor NVD's CPE asserts for this match."),
73
+ vendor_unknown: opt(z.boolean()).describe("true: the vendor was not verified, so the match is real but its attribution to your vendor is not."),
74
+ match_reason: opt(z.string()),
75
+ version_evidence: opt(z.string()),
76
+ });
77
+ const Undetermined = z.looseObject({
78
+ cve_id: opt(z.string()),
79
+ package: opt(z.string()),
80
+ ecosystem: opt(z.string()),
81
+ reason: opt(z.string()),
82
+ detail: opt(z.string()),
83
+ });
84
+ const DATA = z.looseObject({
85
+ product: opt(z.string()),
86
+ version: opt(z.string()),
87
+ vendor: opt(z.string()),
88
+ ecosystem: opt(z.string()),
89
+ ecosystem_recognised: opt(z.boolean()),
90
+ package: opt(z.string()),
91
+ assessed: opt(z.boolean()).describe("Read first. true: the lookup evaluated this component. false: it did not, and count 0 says nothing about whether this version is affected."),
92
+ not_assessed_reason: opt(REASON),
93
+ match_layer: opt(z.string()),
94
+ cve_ids: opt(z.array(z.string())),
95
+ matches: opt(z.array(Match)),
96
+ count: opt(z.number()),
97
+ capped: opt(z.boolean()),
98
+ candidates_capped: opt(z.boolean()),
99
+ candidate_count: opt(z.number()),
100
+ advisories_considered: opt(z.number()),
101
+ excluded_count: opt(z.number()),
102
+ excluded: z.unknown().optional(),
103
+ undetermined_count: opt(z.number()),
104
+ undetermined: opt(z.array(Undetermined)),
105
+ not_affected_count: opt(z.number()),
106
+ degraded: opt(z.boolean()),
107
+ undecidable_excluded_count: opt(z.number()),
108
+ undecided_candidate_count: opt(z.number()),
109
+ product_named_count: opt(z.number()),
110
+ vendor_advisory_count: opt(z.number()),
111
+ });
112
+ const COVERAGE = z.strictObject({
113
+ assessed: z.boolean().nullable().describe("The answer's assessed, first: null when the answer does not say."),
114
+ not_assessed_reason: REASON.nullable(),
115
+ lookup: z.enum(["cpe", "registry"]).describe("Which lookup path this call asked for."),
116
+ match_layer: z.string().nullable(),
117
+ count: z.number().nullable(),
118
+ capped: z.boolean().nullable(),
119
+ candidates_capped: z.boolean().nullable(),
120
+ excluded_count: z.number().nullable(),
121
+ undetermined_count: z.number().nullable(),
122
+ not_affected_count: z.number().nullable(),
123
+ degraded: z.boolean().nullable(),
124
+ });
125
+ // Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
126
+ // this file.
127
+ export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
128
+ export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. Read assessed before count: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there must never be reported as not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON.";
129
+ export const CHECK_AFFECTED_INPUT = z.object({
130
+ product: z.string().optional().describe("CPE path: the NVD CPE product token, such as openssl, nginx or linux_kernel. Leave out for the registry path."),
131
+ ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
132
+ package: z.string().optional().describe("Registry path: the package name in that ecosystem, such as lodash."),
133
+ version: z.string().describe("The version to check, such as 3.0.0."),
134
+ });
135
+ const field = (o, k) => (o !== null && typeof o === "object" ? o[k] : undefined);
136
+ const num = (o, k) => {
137
+ const v = field(o, k);
138
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
139
+ };
140
+ const bool = (o, k) => {
141
+ const v = field(o, k);
142
+ return typeof v === "boolean" ? v : null;
143
+ };
144
+ const str = (o, k) => {
145
+ const v = field(o, k);
146
+ return typeof v === "string" && v ? v : null;
147
+ };
148
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
149
+ // The sentences about the matches themselves: how many are KEV-listed, and which are not scored.
150
+ function matchesNote(matches) {
151
+ const kev = matches.filter((m) => field(m, "kev_listed") === true).length;
152
+ const ransomware = matches.filter((m) => field(m, "ransomware") === true).length;
153
+ const unscored = matches.filter((m) => field(m, "score_assessed") === false).map((m, i) => str(m, "cve_id") ?? `matches[${i}]`);
154
+ const unverified = matches.filter((m) => field(m, "vendor_unknown") === true).length;
155
+ let out = "";
156
+ if (unverified > 0) {
157
+ out += ` ${unverified === matches.length ? "Every match has" : `${plural(unverified, "match has", "matches have")}`} vendor_unknown true: matched on the product name, so the vendor NVD asserts (cpe_vendor) is not verified to be the one you run.`;
158
+ }
159
+ if (kev > 0)
160
+ out += ` ${plural(kev, "of them is", "of them are")} listed in CISA-KEV (kev_listed)${ransomware > 0 ? `, ${ransomware} with known ransomware use` : ""}.`;
161
+ if (unscored.length > 0) {
162
+ const shown = unscored.slice(0, 10).join(", ");
163
+ out += ` NOT YET SCORED (score_assessed: false): ${shown}${unscored.length > 10 ? ` and ${unscored.length - 10} more` : ""}; EchelonGraph has not scored ${unscored.length === 1 ? "it" : "them"}, so an effective_score of zero or an empty effective_severity there means unrated, not harmless.`;
164
+ }
165
+ return out;
166
+ }
167
+ async function checkAffected(kit, a) {
168
+ const product = (a.product ?? "").trim();
169
+ const ecosystem = (a.ecosystem ?? "").trim();
170
+ const pkg = (a.package ?? "").trim();
171
+ const version = (a.version ?? "").trim();
172
+ if (product && (ecosystem || pkg))
173
+ return kit.badInput(TOOL, "give product (the CPE path) or ecosystem and package (the registry path), not both");
174
+ if (!product && !ecosystem && !pkg)
175
+ return kit.badInput(TOOL, "product, or ecosystem and package, is required");
176
+ if (!product && !ecosystem)
177
+ return kit.badInput(TOOL, "ecosystem is required with package");
178
+ if (!product && !pkg)
179
+ return kit.badInput(TOOL, "package is required with ecosystem");
180
+ if (!version)
181
+ return kit.badInput(TOOL, "version is required");
182
+ const lookup = product ? "cpe" : "registry";
183
+ // #1983: percent-encoded, as the backend's publicMatchParam decodes it (url.PathUnescape), so
184
+ // any value is a legal header and a literal + survives.
185
+ const enc = encodeURIComponent;
186
+ const headers = lookup === "cpe"
187
+ ? { [MATCH_HEADERS.product]: enc(product), [MATCH_HEADERS.version]: enc(version) }
188
+ : { [MATCH_HEADERS.ecosystem]: enc(ecosystem), [MATCH_HEADERS.package]: enc(pkg), [MATCH_HEADERS.version]: enc(version) };
189
+ try {
190
+ const r = await kit.api(MATCH_PATH, { headers });
191
+ if (!r.ok)
192
+ return kit.failed(TOOL, r);
193
+ const d = r.data;
194
+ const assessed = bool(d, "assessed");
195
+ const reason = str(d, "not_assessed_reason");
196
+ const matches = Array.isArray(field(d, "matches")) ? field(d, "matches") : [];
197
+ const count = num(d, "count") ?? (Array.isArray(field(d, "matches")) ? matches.length : null);
198
+ const capped = bool(d, "capped");
199
+ const candidatesCapped = bool(d, "candidates_capped");
200
+ const undetermined = num(d, "undetermined_count");
201
+ const notAffected = num(d, "not_affected_count");
202
+ const excluded = num(d, "excluded_count");
203
+ const degraded = bool(d, "degraded");
204
+ const layer = str(d, "match_layer");
205
+ const what = lookup === "cpe" ? `product ${product} at version ${version}` : `${ecosystem} package ${pkg} at version ${version}`;
206
+ // assessed, first.
207
+ let note = kit.okHead(TOOL, r.status);
208
+ if (assessed === true) {
209
+ note += ` (state: measured) assessed: true, so the lookup evaluated ${what}.`;
210
+ }
211
+ else if (assessed === false) {
212
+ const why = reason === null ? "the answer gives no not_assessed_reason." : (REASONS[reason] ?? `the answer gives not_assessed_reason ${reason}, which this version of the server does not describe.`);
213
+ note += ` (state: not_assessed) NOT ASSESSED: assessed is false for ${what}${reason === null ? "" : ` (not_assessed_reason ${reason})`}: ${why} A count of 0 here does not mean this version is not affected: do not report it as unaffected or clean.`;
214
+ }
215
+ else {
216
+ note += ` (state: not_assessed) The answer carries no assessed field (an API older than it sends none), so it does not say whether ${what} was evaluated, and a count of 0 here does not mean this version is not affected.`;
217
+ }
218
+ if (count !== null && count > 0) {
219
+ note += ` AFFECTED: ${plural(count, "CVE matches", "CVEs match")} this version${capped === true ? " (capped: true, so the list stopped at its cap and more may match)" : ""}.${matchesNote(matches)}`;
220
+ }
221
+ else if (count === 0 && assessed === true) {
222
+ note += ` 0 CVEs match this version, a measured result (we looked and found nothing that matches), not a lookup failure.`;
223
+ if (lookup === "registry" && notAffected !== null)
224
+ note += ` ${plural(notAffected, "advisory was", "advisories were")} decided not to affect this version (not_affected_count), and only those are cleared.`;
225
+ }
226
+ if (undetermined !== null && undetermined > 0) {
227
+ note += ` UNDETERMINED: ${plural(undetermined, "advisory names", "advisories name")} this package with a version range that could not be decided at this version (undetermined_count, listed in undetermined): report ${undetermined === 1 ? "it" : "them"} as undetermined, never as safe.`;
228
+ }
229
+ if (candidatesCapped === true && reason !== "candidate_window_truncated")
230
+ note += " candidates_capped is true: not every candidate CVE was loaded, so more CVEs may match than the answer lists.";
231
+ if (excluded !== null && excluded > 0)
232
+ note += ` ${plural(excluded, "candidate CVE was", "candidate CVEs were")} suppressed by the vendor or platform gate (excluded_count, listed in excluded).`;
233
+ if (degraded === true)
234
+ note += " degraded is true: the lookup ran out of time before it finished, so this answer is incomplete.";
235
+ return kit.succeeded(d, note, {
236
+ state: assessed === true ? "measured" : "not_assessed",
237
+ measured_at: null,
238
+ method: (layer ?? lookup) === "registry" ? REGISTRY_METHOD : CPE_METHOD,
239
+ coverage: {
240
+ assessed,
241
+ not_assessed_reason: reason,
242
+ lookup,
243
+ match_layer: layer,
244
+ count,
245
+ capped,
246
+ candidates_capped: candidatesCapped,
247
+ excluded_count: excluded,
248
+ undetermined_count: undetermined,
249
+ not_affected_count: notAffected,
250
+ degraded,
251
+ },
252
+ freshness: null,
253
+ notes: [NO_TIME, NO_FRESHNESS],
254
+ });
255
+ }
256
+ catch (e) {
257
+ return kit.crashed(TOOL, e);
258
+ }
259
+ }
260
+ export function registerCheckAffected(server, kit) {
261
+ const output = kit.envelopeSchema({ data: DATA, coverage: COVERAGE, freshness: null });
262
+ server.registerTool(TOOL, {
263
+ title: CHECK_AFFECTED_TITLE,
264
+ description: CHECK_AFFECTED_DESCRIPTION,
265
+ inputSchema: CHECK_AFFECTED_INPUT,
266
+ outputSchema: output,
267
+ annotations: kit.annotations,
268
+ }, async (a) => kit.checked(TOOL, output, await checkAffected(kit, a)));
269
+ }