echelongraph-mcp 2.7.1 → 2.7.2
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 +20 -0
- package/README.md +9 -7
- package/dist/index.js +149 -43
- package/dist/resources.js +1 -1
- package/dist/tools/cve_intel.js +16 -3
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ with a provenance attestation by the release workflow of
|
|
|
6
6
|
[github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp),
|
|
7
7
|
from the commit tagged `v<version>`.
|
|
8
8
|
|
|
9
|
+
## 2.7.2 — 2026-10-11
|
|
10
|
+
|
|
11
|
+
- exposure_radar flags every radar whose last completed check is past its threshold, not only the
|
|
12
|
+
two whose answer carries a liveness verdict. kev_exposure and exposed_databases (12-hour cycles)
|
|
13
|
+
are flagged after 24 hours, leaked_credentials (a 10-minute cycle) after 1 hour; shadow_ai and
|
|
14
|
+
mcp_servers keep the API's own poller.running and enabled verdicts, and fall back to 30 and 45
|
|
15
|
+
minutes only where the answer gives none. A flagged radar's sentence gives the age and the cycle
|
|
16
|
+
and says its numbers may be stale. The age is taken against the clock when the answer is
|
|
17
|
+
composed, never the stats cache's generated_at. The closing sentence "Each radar refreshes on its
|
|
18
|
+
own schedule." is gone: it said the radars were refreshing while four of the five were stopped.
|
|
19
|
+
It now lists the radars that may be stale, or names each threshold when none is. A test reads
|
|
20
|
+
each radar's cadence from core-backend and fails when a threshold is under twice it (#3010).
|
|
21
|
+
- README: the freshness sentence says a radar refreshes only while it runs (#3010). The setup steps
|
|
22
|
+
say to quit the client fully and reopen it, and no longer give a tool count to look for: `npx`
|
|
23
|
+
runs the newest release and a connector the hosted endpoint's, so a number could differ from a
|
|
24
|
+
working install's and read as a failure (#2813).
|
|
25
|
+
- cve_intel: each exploits row carries `source_published_at`, the source's own date for the
|
|
26
|
+
reference (null where the source gives none), and `eg_first_seen_at`, when EchelonGraph recorded
|
|
27
|
+
it. `first_seen_at` is kept, deprecated, as `source_published_at` under its old key (#2876).
|
|
28
|
+
|
|
9
29
|
## 2.7.1 — 2026-10-06
|
|
10
30
|
|
|
11
31
|
- README: the server serves five prompts, not four; `workload_triage` (added in 2.7.0) is now in
|
package/README.md
CHANGED
|
@@ -34,9 +34,9 @@ API call a tool needs to answer.
|
|
|
34
34
|
## Quick start
|
|
35
35
|
|
|
36
36
|
The local server runs through `npx` and needs Node.js 20 or later; nothing is installed
|
|
37
|
-
globally. Claude Desktop's one-click extension, below, needs neither. Add one of the blocks below
|
|
38
|
-
CVE-2023-44487 actively exploited, and
|
|
39
|
-
have on record for it?"*
|
|
37
|
+
globally. Claude Desktop's one-click extension, below, needs neither. Add one of the blocks below
|
|
38
|
+
to your client, quit it fully and reopen it, then ask: *"Is CVE-2023-44487 actively exploited, and
|
|
39
|
+
how many exposed services does EchelonGraph's radar have on record for it?"*
|
|
40
40
|
|
|
41
41
|
### Claude Desktop
|
|
42
42
|
|
|
@@ -97,8 +97,8 @@ are claude.ai's (see "Remote (no install)" below).
|
|
|
97
97
|
Claude → Quit Claude (⌘Q); on Windows right-click the Claude icon in the system tray and choose
|
|
98
98
|
Quit. The first start can take longer while `npx` downloads the package.
|
|
99
99
|
5. **Check that it worked.** Settings → Developer lists `echelongraph` as running, and the tools
|
|
100
|
-
menu in the chat box lists EchelonGraph's
|
|
101
|
-
section.
|
|
100
|
+
menu in the chat box lists EchelonGraph's tools, the ones under "Tools" below. Then ask the
|
|
101
|
+
question at the top of this section.
|
|
102
102
|
|
|
103
103
|
#### Troubleshooting Claude Desktop
|
|
104
104
|
|
|
@@ -263,8 +263,10 @@ answer of which EchelonGraph holds none, whose `held_since` is after 1 January o
|
|
|
263
263
|
or not known, or whose history is still being read: no advisory from such a vendor is not a finding
|
|
264
264
|
that it published none.
|
|
265
265
|
|
|
266
|
-
Every figure is as fresh as the
|
|
267
|
-
sources on a schedule
|
|
266
|
+
Every figure is as fresh as the last check that produced it. The CVE feed is polled from its
|
|
267
|
+
sources on a schedule. Each radar has its own schedule and refreshes only while it runs, and
|
|
268
|
+
`exposure_radar`'s `freshness` gives each radar's last completed check (`last_run_at`) where the
|
|
269
|
+
API serves one.
|
|
268
270
|
|
|
269
271
|
### How `cve_summary` reads its counts
|
|
270
272
|
|
package/dist/index.js
CHANGED
|
@@ -1206,6 +1206,86 @@ function lastCheck(radar, data) {
|
|
|
1206
1206
|
`${radar}.last_run_at is not the time of every record the ${radar} numbers count, which cover everything still on record, not only what that check found; nor is it ${radar}.generated_at, when the API computed those numbers.`,
|
|
1207
1207
|
];
|
|
1208
1208
|
}
|
|
1209
|
+
// ── Staleness: a radar whose last completed check is older than its cycle allows (#3010) ──
|
|
1210
|
+
//
|
|
1211
|
+
// Through 2.7.1 the note flagged only the two radars whose answer carries a liveness verdict
|
|
1212
|
+
// (shadow_ai's poller.running, mcp_servers.enabled), and every answer ended "Each radar refreshes
|
|
1213
|
+
// on its own schedule.". So during the #2975 scanning pause exposed_databases (77 h since its last
|
|
1214
|
+
// completed check) and leaked_credentials (68 h) read as current. Each radar now has a threshold
|
|
1215
|
+
// keyed to the cadence core-backend runs it at (cmd/server/main.go builds each poller; an interval
|
|
1216
|
+
// of 0 takes the package's defaultInterval):
|
|
1217
|
+
// kev_exposure kevexposure/poller.go defaultInterval, 12 h: 24 h, two cycles
|
|
1218
|
+
// exposed_databases exposeddb/poller.go defaultInterval, 12 h: 24 h, two cycles
|
|
1219
|
+
// leaked_credentials leakedcreds/poller.go defaultInterval, 10 min: 1 h, six cycles, so a stopped
|
|
1220
|
+
// radar shows within the hour and a slow cycle or two never trips it
|
|
1221
|
+
// shadow_ai the API's own poller.running (shadowctlog pollerSilentAfter, 30 min) first;
|
|
1222
|
+
// only when the answer gives no verdict, 30 minutes by poller.last_run_at
|
|
1223
|
+
// mcp_servers the API's own enabled (aiexposure liveWindow, 45 min) first; only when the
|
|
1224
|
+
// answer gives no verdict, 45 minutes by last_run_at
|
|
1225
|
+
// These are copies of numbers core-backend owns, so test/radar-staleness.test.mjs reads the Go files
|
|
1226
|
+
// and fails when a threshold is under twice the cadence it parses, when a cycle named here is not
|
|
1227
|
+
// the one it parses, when a radar has no entry, or when a constant can no longer be parsed.
|
|
1228
|
+
//
|
|
1229
|
+
// The age is taken against this server's clock when the answer is composed, never against a
|
|
1230
|
+
// radar's generated_at: that is when the API's stats cache computed the totals, and the cache is
|
|
1231
|
+
// stale-while-revalidate (core-backend internal/cache/swr.go keeps serving its last value while a
|
|
1232
|
+
// refresh fails), so it can lag the answer by hours and would understate the age. last_run_at is
|
|
1233
|
+
// read fresh (the API omits it when its last good read is more than two minutes old). The note
|
|
1234
|
+
// gives the age and the cycle, says the numbers may be stale, and gives no cause: the answer gives
|
|
1235
|
+
// none.
|
|
1236
|
+
const MINUTE = 60_000;
|
|
1237
|
+
const HOUR = 60 * MINUTE;
|
|
1238
|
+
export const STALE_AFTER = {
|
|
1239
|
+
kev_exposure: { after: 24 * HOUR, cycle: 12 * HOUR },
|
|
1240
|
+
exposed_databases: { after: 24 * HOUR, cycle: 12 * HOUR },
|
|
1241
|
+
leaked_credentials: { after: HOUR, cycle: 10 * MINUTE },
|
|
1242
|
+
shadow_ai: { after: 30 * MINUTE },
|
|
1243
|
+
mcp_servers: { after: 45 * MINUTE },
|
|
1244
|
+
};
|
|
1245
|
+
const counted = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
1246
|
+
// A threshold or a cycle: "24 hours", "1 hour", "45 minutes"; as an adjective, "12-hour".
|
|
1247
|
+
const span = (ms) => (ms % HOUR === 0 ? counted(ms / HOUR, "hour") : counted(Math.round(ms / MINUTE), "minute"));
|
|
1248
|
+
const spanAdj = (ms) => (ms % HOUR === 0 ? `${ms / HOUR}-hour` : `${Math.round(ms / MINUTE)}-minute`);
|
|
1249
|
+
// An age, rounded: minutes under two hours, hours under a week, days past that.
|
|
1250
|
+
const age = (ms) => ms < 2 * HOUR ? counted(Math.round(ms / MINUTE), "minute") : ms < 168 * HOUR ? counted(Math.round(ms / HOUR), "hour") : counted(Math.round(ms / (24 * HOUR)), "day");
|
|
1251
|
+
// How long before `now` the instant `at` was, when that is past the radar's threshold; otherwise
|
|
1252
|
+
// (or when `at` is not a real instant) undefined.
|
|
1253
|
+
function staleBy(radar, at, now) {
|
|
1254
|
+
if (typeof at !== "string" || !realInstant(at))
|
|
1255
|
+
return undefined;
|
|
1256
|
+
const ms = now - Date.parse(at);
|
|
1257
|
+
return ms > STALE_AFTER[radar].after ? ms : undefined;
|
|
1258
|
+
}
|
|
1259
|
+
// The note's sentence for a stats radar whose relayed last_run_at is past its threshold. It does not
|
|
1260
|
+
// repeat the stamp, which lastCheck prints once.
|
|
1261
|
+
function staleCheck(radar, data, now) {
|
|
1262
|
+
const ms = staleBy(radar, data.last_run_at, now);
|
|
1263
|
+
if (ms === undefined)
|
|
1264
|
+
return [];
|
|
1265
|
+
const { after, cycle } = STALE_AFTER[radar];
|
|
1266
|
+
const times = after / cycle === 2 ? "twice" : `${after / cycle} times`;
|
|
1267
|
+
return [`${radar}.last_run_at is about ${age(ms)} before this answer, more than ${times} the radar's ${spanAdj(cycle)} cycle, so the ${radar} numbers may be stale.`];
|
|
1268
|
+
}
|
|
1269
|
+
// The last sentence of every answer. "Each radar refreshes on its own schedule." said the radars
|
|
1270
|
+
// were refreshing whatever their last completed checks said. This says only what the answer
|
|
1271
|
+
// shows: each radar has a cycle of its own (not that it is running on it), and either the radars
|
|
1272
|
+
// that may be stale, the same ones whose own sentences above say so, or that nothing shows one past
|
|
1273
|
+
// its threshold, with each threshold named. "Nothing shows" stays true when the API serves no
|
|
1274
|
+
// last_run_at (#2335: the note then says nothing about a last completed check), and the envelope's
|
|
1275
|
+
// own note (RADAR_FRESHNESS_NOTE) already points to freshness on every answer.
|
|
1276
|
+
function freshnessClose(stale) {
|
|
1277
|
+
if (stale.length)
|
|
1278
|
+
return `Each radar has its own cycle, and ${stale.length} of the ${RADARS.length} radars may be stale: ${listed(stale)}.`;
|
|
1279
|
+
const byAfter = new Map();
|
|
1280
|
+
for (const [name] of RADARS) {
|
|
1281
|
+
const s = STALE_AFTER[name];
|
|
1282
|
+
if (s.cycle !== undefined)
|
|
1283
|
+
byAfter.set(s.after, [...(byAfter.get(s.after) ?? []), name]);
|
|
1284
|
+
}
|
|
1285
|
+
const timed = [...byAfter].map(([after, names]) => `${span(after)} for ${listed(names)}`);
|
|
1286
|
+
const verdicts = `for shadow_ai and mcp_servers, the API's own poller.running and enabled verdicts, or ${span(STALE_AFTER.shadow_ai.after)} and ${span(STALE_AFTER.mcp_servers.after)} where the answer gives none`;
|
|
1287
|
+
return `Each radar has its own cycle, and neither freshness nor anything else in this answer shows a radar past its threshold for calling its numbers stale (${timed.join(", ")}; ${verdicts}).`;
|
|
1288
|
+
}
|
|
1209
1289
|
// The first row of a relayed ranking, for the note: "(first: http_server, 9120 services)".
|
|
1210
1290
|
function firstOf(rows, label, count, unit) {
|
|
1211
1291
|
const top = Array.isArray(rows) ? rows[0] : undefined;
|
|
@@ -1288,7 +1368,7 @@ function newestKEVRow(r) {
|
|
|
1288
1368
|
row.exposed_hosts = hosts;
|
|
1289
1369
|
return { row, state, outside: tracked === false };
|
|
1290
1370
|
}
|
|
1291
|
-
function readKEVExposure(body) {
|
|
1371
|
+
function readKEVExposure(body, now) {
|
|
1292
1372
|
const { data, unknown, unusable, undated } = readRadar(body, KEV_SPEC);
|
|
1293
1373
|
const sentences = [];
|
|
1294
1374
|
const n = (k) => numIn(data, k);
|
|
@@ -1346,8 +1426,10 @@ function readKEVExposure(body) {
|
|
|
1346
1426
|
}
|
|
1347
1427
|
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"));
|
|
1348
1428
|
sentences.push(...lastCheck("kev_exposure", data));
|
|
1429
|
+
const stale = staleCheck("kev_exposure", data, now);
|
|
1430
|
+
sentences.push(...stale);
|
|
1349
1431
|
sentences.push(...leftOut("kev_exposure", unknown, unusable));
|
|
1350
|
-
return { data, sentences };
|
|
1432
|
+
return { data, sentences, stale: stale.length > 0 };
|
|
1351
1433
|
}
|
|
1352
1434
|
// ── exposed_databases: GET /api/v1/public/exposed-databases/stats (#2313 item 7) ──
|
|
1353
1435
|
//
|
|
@@ -1375,7 +1457,7 @@ const EXPOSED_DB_SPEC = {
|
|
|
1375
1457
|
instants: ["generated_at", "last_run_at"],
|
|
1376
1458
|
window: "observed_window",
|
|
1377
1459
|
};
|
|
1378
|
-
function readExposedDatabases(body) {
|
|
1460
|
+
function readExposedDatabases(body, now) {
|
|
1379
1461
|
const { data, unknown, unusable, undated } = readRadar(body, EXPOSED_DB_SPEC);
|
|
1380
1462
|
const sentences = [];
|
|
1381
1463
|
const n = (k) => numIn(data, k);
|
|
@@ -1401,8 +1483,10 @@ function readExposedDatabases(body) {
|
|
|
1401
1483
|
}
|
|
1402
1484
|
sentences.push(...windowSentences("exposed_databases", { window: data.window, undated, bad: false }, "when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication"));
|
|
1403
1485
|
sentences.push(...lastCheck("exposed_databases", data));
|
|
1486
|
+
const stale = staleCheck("exposed_databases", data, now);
|
|
1487
|
+
sentences.push(...stale);
|
|
1404
1488
|
sentences.push(...leftOut("exposed_databases", unknown, unusable));
|
|
1405
|
-
return { data, sentences };
|
|
1489
|
+
return { data, sentences, stale: stale.length > 0 };
|
|
1406
1490
|
}
|
|
1407
1491
|
// ── leaked_credentials: GET /api/v1/public/leaked-credentials/stats (#2313 item 7) ──
|
|
1408
1492
|
//
|
|
@@ -1425,7 +1509,7 @@ const LEAKED_CREDS_SPEC = {
|
|
|
1425
1509
|
instants: ["generated_at", "last_run_at"],
|
|
1426
1510
|
window: "observed_window",
|
|
1427
1511
|
};
|
|
1428
|
-
function readLeakedCredentials(body) {
|
|
1512
|
+
function readLeakedCredentials(body, now) {
|
|
1429
1513
|
const { data, unknown, unusable, undated } = readRadar(body, LEAKED_CREDS_SPEC);
|
|
1430
1514
|
const sentences = [];
|
|
1431
1515
|
const n = (k) => numIn(data, k);
|
|
@@ -1448,10 +1532,12 @@ function readLeakedCredentials(body) {
|
|
|
1448
1532
|
}
|
|
1449
1533
|
sentences.push(...windowSentences("leaked_credentials", { window: data.window, undated, bad: false }, "when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit"));
|
|
1450
1534
|
sentences.push(...lastCheck("leaked_credentials", data));
|
|
1535
|
+
const stale = staleCheck("leaked_credentials", data, now);
|
|
1536
|
+
sentences.push(...stale);
|
|
1451
1537
|
sentences.push(...leftOut("leaked_credentials", unknown, unusable));
|
|
1452
|
-
return { data, sentences };
|
|
1538
|
+
return { data, sentences, stale: stale.length > 0 };
|
|
1453
1539
|
}
|
|
1454
|
-
function readShadowAI(sa) {
|
|
1540
|
+
function readShadowAI(sa, now) {
|
|
1455
1541
|
const sentences = [];
|
|
1456
1542
|
const stats = field(sa, "stats");
|
|
1457
1543
|
const used = new Set();
|
|
@@ -1574,6 +1660,10 @@ function readShadowAI(sa) {
|
|
|
1574
1660
|
data.authentication = authentication;
|
|
1575
1661
|
const lastRun = strAt(sa, "poller", "last_run_at");
|
|
1576
1662
|
const running = boolAt(sa, "poller", "running");
|
|
1663
|
+
// #3010: running is the API's own verdict and decides; only a block with a real last_run_at and
|
|
1664
|
+
// no verdict is judged by STALE_AFTER.shadow_ai, the API's own window, and that time is still
|
|
1665
|
+
// not relayed (the block stays left out) or repeated: the note gives its age.
|
|
1666
|
+
const unjudged = running === undefined ? staleBy("shadow_ai", lastRun, now) : undefined;
|
|
1577
1667
|
if (realInstant(lastRun) && running === true) {
|
|
1578
1668
|
data.poller = { running, last_run_at: lastRun };
|
|
1579
1669
|
sentences.push(`Shadow-AI radar freshness: the radar's leader last completed a Certificate Transparency (crt.sh) discovery cycle at ${lastRun} (poller.running is true: that was within ${SHADOW_AI_SILENT_AFTER} of the API's answer).`);
|
|
@@ -1587,10 +1677,13 @@ function readShadowAI(sa) {
|
|
|
1587
1677
|
}
|
|
1588
1678
|
else {
|
|
1589
1679
|
sentences.push("Shadow-AI radar freshness is unknown: the answer's poller block does not give both a real time at which the radar's leader last completed a cycle (poller.last_run_at) and a running verdict (poller.running), so that block was left out of the data above.");
|
|
1680
|
+
if (unjudged !== undefined) {
|
|
1681
|
+
sentences.push(`The shadow-AI poller block gives no running verdict, but its last_run_at is about ${age(unjudged)} before this answer, more than the ${span(STALE_AFTER.shadow_ai.after)} after which the API reports the radar not running, so the shadow-AI figures may be stale.`);
|
|
1682
|
+
}
|
|
1590
1683
|
}
|
|
1591
1684
|
if (realInstant(lastObservation))
|
|
1592
1685
|
sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
|
|
1593
|
-
return { data, sentences };
|
|
1686
|
+
return { data, sentences, stale: (realInstant(lastRun) && running === false) || unjudged !== undefined };
|
|
1594
1687
|
}
|
|
1595
1688
|
// ── mcp_servers: GET /api/v1/public/ai-exposure/stats?service=mcp (#2315) ──
|
|
1596
1689
|
//
|
|
@@ -1716,7 +1809,7 @@ function mcpRefusal(body) {
|
|
|
1716
1809
|
}
|
|
1717
1810
|
return undefined;
|
|
1718
1811
|
}
|
|
1719
|
-
function readMCPServers(src) {
|
|
1812
|
+
function readMCPServers(src, now) {
|
|
1720
1813
|
const data = {};
|
|
1721
1814
|
const unusable = [];
|
|
1722
1815
|
const unsummed = [];
|
|
@@ -1807,12 +1900,18 @@ function readMCPServers(src) {
|
|
|
1807
1900
|
if (typeof at === "string") {
|
|
1808
1901
|
sentences.push(`mcp_servers last completed check: ${at} (mcp_servers.last_run_at, a timestamp, not a count).`, "mcp_servers.last_run_at is when the AI-exposure radar, which checks other AI services as well as MCP servers, last finished a cycle whose reads succeeded and whose scan opt-out register answered.", "mcp_servers.last_run_at is not the time of every verdict the mcp_servers numbers count (mcp_servers.window says when those were checked); nor is it mcp_servers.counted_at, when the API read those numbers.");
|
|
1809
1902
|
}
|
|
1903
|
+
// #3010: enabled is the API's own verdict and decides; only an answer without a usable one is
|
|
1904
|
+
// judged by STALE_AFTER.mcp_servers, the API's own window, on the last_run_at relayed above.
|
|
1905
|
+
const unjudged = data.enabled === undefined ? staleBy("mcp_servers", data.last_run_at, now) : undefined;
|
|
1810
1906
|
if (data.enabled === true) {
|
|
1811
1907
|
sentences.push(`mcp_servers.enabled is true: the API reports the AI-exposure radar running, a check having completed within ${MCP_ENABLED_WITHIN} of its answer.`);
|
|
1812
1908
|
}
|
|
1813
1909
|
else if (data.enabled === false) {
|
|
1814
1910
|
sentences.push(`mcp_servers.enabled is false: the API does not report the AI-exposure radar running (no check completed within ${MCP_ENABLED_WITHIN} of its answer), so these numbers may be stale.`);
|
|
1815
1911
|
}
|
|
1912
|
+
else if (unjudged !== undefined) {
|
|
1913
|
+
sentences.push(`The answer gives no usable mcp_servers.enabled verdict, but mcp_servers.last_run_at is about ${age(unjudged)} before this answer, more than the ${span(STALE_AFTER.mcp_servers.after)} within which the API reports the AI-exposure radar running, so the mcp_servers numbers may be stale.`);
|
|
1914
|
+
}
|
|
1816
1915
|
if (typeof data.counted_at === "string") {
|
|
1817
1916
|
sentences.push(`mcp_servers.counted_at (${data.counted_at}) is when the API read these counts: a timestamp, not a count, and not when any verdict was checked.`);
|
|
1818
1917
|
}
|
|
@@ -1822,7 +1921,7 @@ function readMCPServers(src) {
|
|
|
1822
1921
|
if (unnamed) {
|
|
1823
1922
|
sentences.push(`Also left out of mcp_servers: ${unnamed} field${unnamed === 1 ? "" : "s"} whose name is not shaped like a field name, not repeated here, since a name could itself identify a server.`);
|
|
1824
1923
|
}
|
|
1825
|
-
return { data, sentences };
|
|
1924
|
+
return { data, sentences, stale: data.enabled === false || unjudged !== undefined };
|
|
1826
1925
|
}
|
|
1827
1926
|
// #2842: the description is a head that fits the client cut (requiredText.ts); what each radar's
|
|
1828
1927
|
// numbers count, field by field, is the outputSchema's description of that radar (RADAR_*_FIELDS
|
|
@@ -1858,7 +1957,7 @@ const RADAR_MCP_FIELDS = `mcp_servers: counts and timestamps only, no hostname.
|
|
|
1858
1957
|
// not_assessed with measured_at null, the window is relayed in data and labelled by the note,
|
|
1859
1958
|
// and the envelope's own note names it.
|
|
1860
1959
|
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.";
|
|
1861
|
-
const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1960
|
+
export const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1862
1961
|
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.";
|
|
1863
1962
|
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.";
|
|
1864
1963
|
async function exposureRadar() {
|
|
@@ -1891,43 +1990,50 @@ async function exposureRadar() {
|
|
|
1891
1990
|
const radars = failures.map(([radar, f]) => ({ radar, kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail }));
|
|
1892
1991
|
return failure("failed", message, { kind: "radars", path: null, status: null, message: `${failures.length} of ${RADARS.length} radars could not be read`, radars }, { radars: RADAR_NAMES, answered, failed: failures.map(([name]) => name) });
|
|
1893
1992
|
}
|
|
1894
|
-
|
|
1895
|
-
const read = {
|
|
1896
|
-
kev_exposure: readKEVExposure(data.kev_exposure),
|
|
1897
|
-
exposed_databases: readExposedDatabases(data.exposed_databases),
|
|
1898
|
-
leaked_credentials: readLeakedCredentials(data.leaked_credentials),
|
|
1899
|
-
shadow_ai: readShadowAI(data.shadow_ai),
|
|
1900
|
-
mcp_servers: readMCPServers(data.mcp_servers),
|
|
1901
|
-
};
|
|
1902
|
-
const relayed = {};
|
|
1903
|
-
const said = [];
|
|
1904
|
-
for (const [name] of RADARS) {
|
|
1905
|
-
relayed[name] = read[name].data;
|
|
1906
|
-
said.push(...read[name].sentences);
|
|
1907
|
-
}
|
|
1908
|
-
// Only what the readers relayed: a last_run_at that is not a real instant never got there.
|
|
1909
|
-
const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
|
|
1910
|
-
const running = boolAt(relayed.shadow_ai, "poller", "running");
|
|
1911
|
-
const enabled = boolAt(relayed.mcp_servers, "enabled");
|
|
1912
|
-
return succeeded(relayed, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`, {
|
|
1913
|
-
state: "not_assessed",
|
|
1914
|
-
measured_at: null,
|
|
1915
|
-
method: RADAR_METHOD,
|
|
1916
|
-
coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
|
|
1917
|
-
freshness: {
|
|
1918
|
-
kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
|
|
1919
|
-
exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
|
|
1920
|
-
leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
|
|
1921
|
-
shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
|
|
1922
|
-
mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
|
|
1923
|
-
},
|
|
1924
|
-
notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
|
|
1925
|
-
});
|
|
1993
|
+
return radarAnswer(data, Date.now());
|
|
1926
1994
|
}
|
|
1927
1995
|
catch (e) {
|
|
1928
1996
|
return crashed(tool, e);
|
|
1929
1997
|
}
|
|
1930
1998
|
}
|
|
1999
|
+
// exposure_radar's answer once every radar has answered (and mcp_servers has passed mcpRefusal):
|
|
2000
|
+
// each radar's answer relayed cut to the fields its reader can label, in RADARS order, and the
|
|
2001
|
+
// note. `now` is the clock the radars' last completed checks are aged against (#3010; see
|
|
2002
|
+
// STALE_AFTER), a parameter so that the suite can set it.
|
|
2003
|
+
export function radarAnswer(data, now) {
|
|
2004
|
+
const read = {
|
|
2005
|
+
kev_exposure: readKEVExposure(data.kev_exposure, now),
|
|
2006
|
+
exposed_databases: readExposedDatabases(data.exposed_databases, now),
|
|
2007
|
+
leaked_credentials: readLeakedCredentials(data.leaked_credentials, now),
|
|
2008
|
+
shadow_ai: readShadowAI(data.shadow_ai, now),
|
|
2009
|
+
mcp_servers: readMCPServers(data.mcp_servers, now),
|
|
2010
|
+
};
|
|
2011
|
+
const relayed = {};
|
|
2012
|
+
const said = [];
|
|
2013
|
+
for (const [name] of RADARS) {
|
|
2014
|
+
relayed[name] = read[name].data;
|
|
2015
|
+
said.push(...read[name].sentences);
|
|
2016
|
+
}
|
|
2017
|
+
const stale = RADAR_NAMES.filter((name) => read[name].stale);
|
|
2018
|
+
// Only what the readers relayed: a last_run_at that is not a real instant never got there.
|
|
2019
|
+
const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
|
|
2020
|
+
const running = boolAt(relayed.shadow_ai, "poller", "running");
|
|
2021
|
+
const enabled = boolAt(relayed.mcp_servers, "enabled");
|
|
2022
|
+
return succeeded(relayed, `${okHead("exposure_radar")} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} ${freshnessClose(stale)}`, {
|
|
2023
|
+
state: "not_assessed",
|
|
2024
|
+
measured_at: null,
|
|
2025
|
+
method: RADAR_METHOD,
|
|
2026
|
+
coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
|
|
2027
|
+
freshness: {
|
|
2028
|
+
kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
|
|
2029
|
+
exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
|
|
2030
|
+
leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
|
|
2031
|
+
shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
|
|
2032
|
+
mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
|
|
2033
|
+
},
|
|
2034
|
+
notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
1931
2037
|
// ── outputSchema: the envelope, per tool (#2311, #2313) ──
|
|
1932
2038
|
//
|
|
1933
2039
|
// A discriminated union on state: a success (measured, not_assessed) carries data and a method;
|
package/dist/resources.js
CHANGED
|
@@ -63,7 +63,7 @@ A count of 0 is a finding of none when the answer measured it. Under assessed: f
|
|
|
63
63
|
- 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.
|
|
64
64
|
- 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.
|
|
65
65
|
- scan_manifest: as check_sbom, on the purls this server reads from the lockfiles passed; not_assessed, with no API call, when no entry pins one registry version. Each entry not sent (a range, an unresolved version, a local path, a VCS checkout or a line not read) is listed in data.not_checked with its reason and is not clean; a refused file (package.json, go.sum and the other manifests of ranges) is named in the note, and nothing in it is checked.
|
|
66
|
-
- cve_intel: measured_at null: each row carries its own first_seen_at
|
|
66
|
+
- cve_intel: measured_at null: each row carries its own dates. An exploits row carries source_published_at, the source's own date for the reference, null when the source publishes none (EchelonGraph does not supply one), and eg_first_seen_at, when EchelonGraph recorded the reference; first_seen_at is deprecated and holds the same value as source_published_at. A timeline row carries enriched_at. An empty exploits list is not evidence that no public exploit exists.
|
|
67
67
|
- cve_remediation: measured with measured_at null, or not_assessed for a CVE record rejected by its numbering authority. Every text is its source's, verbatim and untested by EchelonGraph; each vendor item's kind is the vendor's own category. remediation_state none_in_source or not_parsed, and an empty list, are not a finding that no fix exists.
|
|
68
68
|
- 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.
|
|
69
69
|
- 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.
|
package/dist/tools/cve_intel.js
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
// does not say which ran, so no text here says a source is polled: it names the sources the data
|
|
26
26
|
// model holds, and says coverage depends on which pollers run.
|
|
27
27
|
//
|
|
28
|
+
// Exploit dates (#2876). Until #2876 the collectors made dates up: a nuclei row's first_seen_at
|
|
29
|
+
// was 1 January of its CVE's year, and a Metasploit row's was the vulnerability's disclosure
|
|
30
|
+
// date. Each row now carries source_published_at (the source's own date, null where it gives
|
|
31
|
+
// none) and eg_first_seen_at (when EchelonGraph recorded it); first_seen_at, deprecated, is
|
|
32
|
+
// source_published_at under its old key. All three are relayed as sent, null included.
|
|
33
|
+
//
|
|
28
34
|
// Fixed versions (#2817). /enrichment keeps one affected_packages row per (CVE, ecosystem,
|
|
29
35
|
// package), and its fixed_version is one range's fix: the last range's as the advisory was read
|
|
30
36
|
// (core-backend cve/backfill_osv_packages.go summariseAffected). For CVE-2021-44228 production
|
|
@@ -70,7 +76,10 @@ const TIMELINE_RELAYED = 20;
|
|
|
70
76
|
const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
|
|
71
77
|
const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
|
|
72
78
|
const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed is the fix for it, and a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
|
|
73
|
-
|
|
79
|
+
// #2876: the exploit dates are named for what they are. Within the 100 characters of headroom
|
|
80
|
+
// under the 2048-character client cut (prompts-resources.test.mjs), the room came from tighter
|
|
81
|
+
// glosses and from the input hint, which inputSchema's cve_id description carries.
|
|
82
|
+
export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with name and source), exploits (each with kind, source_name, source_url, source_published_at (the source's date, null if none), eg_first_seen_at (when EchelonGraph recorded it) and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (each fixed version with the vulnerable_range it fixes) and timeline (the newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range on record (introduced, and fixed or last_affected, with advisory_id (its OSV record) and source); in the ecosystem's version order, a version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range's fixed is the fix for it. fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is the source's label, not a guarantee the exploit works on a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
|
|
74
83
|
// ── outputSchema ──
|
|
75
84
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
76
85
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -87,7 +96,11 @@ function schemas() {
|
|
|
87
96
|
source_name: opt(z.string()),
|
|
88
97
|
source_url: opt(z.string()),
|
|
89
98
|
description: opt(z.string()),
|
|
90
|
-
|
|
99
|
+
source_published_at: opt(z.string()).describe("The source's own date for the reference (Exploit-DB's date_published, a GitHub proof-of-concept repository's created_at, or the first commit of a nuclei template or Metasploit module where EchelonGraph derived it from the source's git history); null where the source gives none, and EchelonGraph supplies no stand-in date."),
|
|
100
|
+
// Optional, not nullable: core-backend always serves it since #2876 (cve_exploits.created_at);
|
|
101
|
+
// an API older than #2876 leaves it out.
|
|
102
|
+
eg_first_seen_at: z.string().optional().describe("When EchelonGraph first recorded the reference, as a UTC timestamp: a date of EchelonGraph's, not the source's."),
|
|
103
|
+
first_seen_at: opt(z.string()).describe("Deprecated: the same value as source_published_at, null when that is."),
|
|
91
104
|
verified_status: opt(z.string()).describe(VERIFIED_STATUS_DESCRIPTION),
|
|
92
105
|
}).optional(),
|
|
93
106
|
exploits_total: z.number().int().nullable().optional().describe("Every exploit reference on record for the CVE, not only those listed in exploits."),
|
|
@@ -323,7 +336,7 @@ export async function cveIntel(cve_id) {
|
|
|
323
336
|
coverage,
|
|
324
337
|
freshness: null,
|
|
325
338
|
notes: [
|
|
326
|
-
"measured_at is null: the answer has no single observation time; each row carries its own (
|
|
339
|
+
"measured_at is null: the answer has no single observation time; each row carries its own (source_published_at and eg_first_seen_at, enriched_at).",
|
|
327
340
|
"freshness is null: the answer does not say when each source was last polled.",
|
|
328
341
|
],
|
|
329
342
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.2",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "2.7.
|
|
20
|
+
"version": "2.7.2",
|
|
21
21
|
"remotes": [
|
|
22
22
|
{
|
|
23
23
|
"type": "streamable-http",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
{
|
|
29
29
|
"registryType": "npm",
|
|
30
30
|
"identifier": "echelongraph-mcp",
|
|
31
|
-
"version": "2.7.
|
|
31
|
+
"version": "2.7.2",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|