dns-security-mcp 0.1.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +723 -0
  3. package/dist/blocklist/index.d.ts +3 -0
  4. package/dist/blocklist/index.d.ts.map +1 -0
  5. package/dist/blocklist/index.js +596 -0
  6. package/dist/blocklist/index.js.map +1 -0
  7. package/dist/ct/index.d.ts +3 -0
  8. package/dist/ct/index.d.ts.map +1 -0
  9. package/dist/ct/index.js +534 -0
  10. package/dist/ct/index.js.map +1 -0
  11. package/dist/data/dkim-selectors.d.ts +2 -0
  12. package/dist/data/dkim-selectors.d.ts.map +1 -0
  13. package/dist/data/dkim-selectors.js +60 -0
  14. package/dist/data/dkim-selectors.js.map +1 -0
  15. package/dist/data/dnsbl-lists.d.ts +8 -0
  16. package/dist/data/dnsbl-lists.d.ts.map +1 -0
  17. package/dist/data/dnsbl-lists.js +54 -0
  18. package/dist/data/dnsbl-lists.js.map +1 -0
  19. package/dist/data/takeover-fingerprints.d.ts +8 -0
  20. package/dist/data/takeover-fingerprints.d.ts.map +1 -0
  21. package/dist/data/takeover-fingerprints.js +84 -0
  22. package/dist/data/takeover-fingerprints.js.map +1 -0
  23. package/dist/data/tunneling-signatures.d.ts +17 -0
  24. package/dist/data/tunneling-signatures.d.ts.map +1 -0
  25. package/dist/data/tunneling-signatures.js +85 -0
  26. package/dist/data/tunneling-signatures.js.map +1 -0
  27. package/dist/dns/index.d.ts +3 -0
  28. package/dist/dns/index.d.ts.map +1 -0
  29. package/dist/dns/index.js +1211 -0
  30. package/dist/dns/index.js.map +1 -0
  31. package/dist/dnssec/index.d.ts +3 -0
  32. package/dist/dnssec/index.d.ts.map +1 -0
  33. package/dist/dnssec/index.js +1377 -0
  34. package/dist/dnssec/index.js.map +1 -0
  35. package/dist/domain/index.d.ts +3 -0
  36. package/dist/domain/index.d.ts.map +1 -0
  37. package/dist/domain/index.js +938 -0
  38. package/dist/domain/index.js.map +1 -0
  39. package/dist/email/index.d.ts +3 -0
  40. package/dist/email/index.d.ts.map +1 -0
  41. package/dist/email/index.js +1188 -0
  42. package/dist/email/index.js.map +1 -0
  43. package/dist/hijack/index.d.ts +3 -0
  44. package/dist/hijack/index.d.ts.map +1 -0
  45. package/dist/hijack/index.js +1117 -0
  46. package/dist/hijack/index.js.map +1 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +151 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/infra/index.d.ts +3 -0
  52. package/dist/infra/index.d.ts.map +1 -0
  53. package/dist/infra/index.js +797 -0
  54. package/dist/infra/index.js.map +1 -0
  55. package/dist/privacy/index.d.ts +3 -0
  56. package/dist/privacy/index.d.ts.map +1 -0
  57. package/dist/privacy/index.js +772 -0
  58. package/dist/privacy/index.js.map +1 -0
  59. package/dist/protocol/mcp-server.d.ts +4 -0
  60. package/dist/protocol/mcp-server.d.ts.map +1 -0
  61. package/dist/protocol/mcp-server.js +32 -0
  62. package/dist/protocol/mcp-server.js.map +1 -0
  63. package/dist/protocol/tools.d.ts +3 -0
  64. package/dist/protocol/tools.d.ts.map +1 -0
  65. package/dist/protocol/tools.js +29 -0
  66. package/dist/protocol/tools.js.map +1 -0
  67. package/dist/report/index.d.ts +3 -0
  68. package/dist/report/index.d.ts.map +1 -0
  69. package/dist/report/index.js +1167 -0
  70. package/dist/report/index.js.map +1 -0
  71. package/dist/threat/index.d.ts +3 -0
  72. package/dist/threat/index.d.ts.map +1 -0
  73. package/dist/threat/index.js +999 -0
  74. package/dist/threat/index.js.map +1 -0
  75. package/dist/tunnel/index.d.ts +3 -0
  76. package/dist/tunnel/index.d.ts.map +1 -0
  77. package/dist/tunnel/index.js +688 -0
  78. package/dist/tunnel/index.js.map +1 -0
  79. package/dist/types/index.d.ts +52 -0
  80. package/dist/types/index.d.ts.map +1 -0
  81. package/dist/types/index.js +8 -0
  82. package/dist/types/index.js.map +1 -0
  83. package/dist/typo/index.d.ts +3 -0
  84. package/dist/typo/index.d.ts.map +1 -0
  85. package/dist/typo/index.js +625 -0
  86. package/dist/typo/index.js.map +1 -0
  87. package/dist/utils/cache.d.ts +11 -0
  88. package/dist/utils/cache.d.ts.map +1 -0
  89. package/dist/utils/cache.js +35 -0
  90. package/dist/utils/cache.js.map +1 -0
  91. package/dist/utils/dns-client.d.ts +37 -0
  92. package/dist/utils/dns-client.d.ts.map +1 -0
  93. package/dist/utils/dns-client.js +359 -0
  94. package/dist/utils/dns-client.js.map +1 -0
  95. package/dist/utils/rate-limiter.d.ts +10 -0
  96. package/dist/utils/rate-limiter.d.ts.map +1 -0
  97. package/dist/utils/rate-limiter.js +35 -0
  98. package/dist/utils/rate-limiter.js.map +1 -0
  99. package/package.json +63 -0
@@ -0,0 +1,999 @@
1
+ import { z } from "zod";
2
+ import { text, json } from "../types/index.js";
3
+ import { createResolver, resolveAll, reverseIp, shannonEntropy } from "../utils/dns-client.js";
4
+ import { TTLCache } from "../utils/cache.js";
5
+ import { RateLimiter } from "../utils/rate-limiter.js";
6
+ import * as dns from "node:dns/promises";
7
+ // ─── Constants ───
8
+ const FETCH_TIMEOUT = 10_000;
9
+ const CRT_SH_BASE = "https://crt.sh";
10
+ // ─── Rate Limiter & Cache ───
11
+ const threatLimiter = new RateLimiter(500);
12
+ const threatCache = new TTLCache(5 * 60 * 1000); // 5 min
13
+ // ─── Known Sinkhole IP Ranges & Patterns ───
14
+ const SINKHOLE_OPERATORS = [
15
+ {
16
+ operator: "Microsoft Digital Crimes Unit",
17
+ ranges: [
18
+ { prefix: "157.56.149.", cidr: 24 },
19
+ { prefix: "204.95.99.", cidr: 24 },
20
+ ],
21
+ patterns: [],
22
+ },
23
+ {
24
+ operator: "Shadowserver Foundation",
25
+ ranges: [
26
+ { prefix: "74.82.47.", cidr: 24 },
27
+ { prefix: "184.105.176.", cidr: 24 },
28
+ { prefix: "184.105.192.", cidr: 24 },
29
+ ],
30
+ patterns: [],
31
+ },
32
+ {
33
+ operator: "abuse.ch",
34
+ ranges: [
35
+ { prefix: "195.154.169.", cidr: 24 },
36
+ ],
37
+ patterns: ["sinkhole.abuse.ch"],
38
+ },
39
+ {
40
+ operator: "FBI / IC3",
41
+ ranges: [
42
+ { prefix: "153.31.120.", cidr: 24 },
43
+ ],
44
+ patterns: [],
45
+ },
46
+ {
47
+ operator: "Palo Alto Networks",
48
+ ranges: [],
49
+ patterns: ["sinkhole.paloaltonetworks.com"],
50
+ },
51
+ {
52
+ operator: "Kaspersky",
53
+ ranges: [
54
+ { prefix: "77.74.181.", cidr: 24 },
55
+ { prefix: "77.74.182.", cidr: 24 },
56
+ ],
57
+ patterns: ["sinkhole.kaspersky.com"],
58
+ },
59
+ {
60
+ operator: "CrowdStrike",
61
+ ranges: [],
62
+ patterns: ["sinkhole.crowdstrike.com"],
63
+ },
64
+ {
65
+ operator: "CERT.PL",
66
+ ranges: [
67
+ { prefix: "148.81.111.", cidr: 24 },
68
+ ],
69
+ patterns: ["sinkhole.cert.pl"],
70
+ },
71
+ {
72
+ operator: "Spamhaus",
73
+ ranges: [],
74
+ patterns: ["sinkhole.spamhaus.org"],
75
+ },
76
+ {
77
+ operator: "Zinkhole / Georgia Tech",
78
+ ranges: [
79
+ { prefix: "143.215.130.", cidr: 24 },
80
+ ],
81
+ patterns: [],
82
+ },
83
+ {
84
+ operator: "Cloudflare",
85
+ ranges: [],
86
+ patterns: ["sinkhole.cloudflare.com"],
87
+ },
88
+ {
89
+ operator: "Akamai",
90
+ ranges: [],
91
+ patterns: ["sinkhole.akamai.com"],
92
+ },
93
+ {
94
+ operator: "FireEye / Mandiant",
95
+ ranges: [
96
+ { prefix: "66.220.23.", cidr: 24 },
97
+ ],
98
+ patterns: [],
99
+ },
100
+ {
101
+ operator: "Arbor Networks / Netscout",
102
+ ranges: [
103
+ { prefix: "216.218.185.", cidr: 24 },
104
+ ],
105
+ patterns: [],
106
+ },
107
+ ];
108
+ async function queryCrtSh(query) {
109
+ await threatLimiter.acquire();
110
+ const url = `${CRT_SH_BASE}/?q=${encodeURIComponent(query)}&output=json`;
111
+ const res = await fetch(url, {
112
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
113
+ headers: { Accept: "application/json" },
114
+ });
115
+ if (!res.ok)
116
+ throw new Error(`crt.sh error: ${res.status} ${res.statusText}`);
117
+ const data = await res.json();
118
+ return data ?? [];
119
+ }
120
+ function ipMatchesPrefix(ip, prefix) {
121
+ return ip.startsWith(prefix);
122
+ }
123
+ function consonantRatio(str) {
124
+ const consonants = str.replace(/[^bcdfghjklmnpqrstvwxyz]/gi, "");
125
+ const alpha = str.replace(/[^a-z]/gi, "");
126
+ if (alpha.length === 0)
127
+ return 0;
128
+ return consonants.length / alpha.length;
129
+ }
130
+ async function lookupDnsbl(query, dnsblDomain) {
131
+ const resolver = createResolver();
132
+ try {
133
+ const results = await resolver.resolve4(`${query}.${dnsblDomain}`);
134
+ return results.length > 0;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ async function cymruAsnLookup(ip) {
141
+ const resolver = createResolver();
142
+ try {
143
+ const reversed = reverseIp(ip);
144
+ const txtResults = await resolver.resolveTxt(`${reversed}.origin.asn.cymru.com`);
145
+ if (txtResults.length === 0)
146
+ return null;
147
+ const parts = txtResults[0].join("").split("|").map((s) => s.trim());
148
+ const asn = parts[0] ?? "unknown";
149
+ const prefix = parts[1] ?? "unknown";
150
+ // Lookup AS name
151
+ let asName = "unknown";
152
+ try {
153
+ const nameResults = await resolver.resolveTxt(`AS${asn}.asn.cymru.com`);
154
+ if (nameResults.length > 0) {
155
+ const nameParts = nameResults[0].join("").split("|").map((s) => s.trim());
156
+ asName = nameParts[4] ?? "unknown";
157
+ }
158
+ }
159
+ catch {
160
+ // AS name lookup failed — not critical
161
+ }
162
+ return { asn, prefix, asName };
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ async function rdapLookup(domain) {
169
+ try {
170
+ const res = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`, {
171
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
172
+ headers: { Accept: "application/rdap+json" },
173
+ });
174
+ if (!res.ok)
175
+ return null;
176
+ const data = (await res.json());
177
+ let registrar;
178
+ const entities = data.entities;
179
+ if (entities) {
180
+ for (const entity of entities) {
181
+ if (entity.roles?.includes("registrar")) {
182
+ const vcard = entity.vcardArray;
183
+ if (vcard && Array.isArray(vcard[1])) {
184
+ for (const entry of vcard[1]) {
185
+ if (entry[0] === "fn") {
186
+ registrar = entry[3];
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ const events = data.events;
194
+ let created;
195
+ let updated;
196
+ let expires;
197
+ if (events) {
198
+ for (const ev of events) {
199
+ if (ev.eventAction === "registration")
200
+ created = ev.eventDate;
201
+ if (ev.eventAction === "last changed")
202
+ updated = ev.eventDate;
203
+ if (ev.eventAction === "expiration")
204
+ expires = ev.eventDate;
205
+ }
206
+ }
207
+ return { registrar, created, updated, expires };
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ // ─── Tool 1: threat_passive_dns ───
214
+ const threatPassiveDns = {
215
+ name: "threat_passive_dns",
216
+ description: "Query passive DNS data for a domain. Uses SecurityTrails API if SECURITYTRAILS_API_KEY is set, " +
217
+ "otherwise falls back to Certificate Transparency logs (crt.sh) for historical cert data plus " +
218
+ "current multi-resolver comparison. Returns historical IPs, first/last seen timestamps.",
219
+ schema: {
220
+ domain: z.string().describe("The domain to query passive DNS history for (e.g. 'example.com')"),
221
+ },
222
+ async execute(args, ctx) {
223
+ const domain = args.domain;
224
+ const cacheKey = `threat_passive_dns:${domain}`;
225
+ const cached = threatCache.get(cacheKey);
226
+ if (cached)
227
+ return json(cached);
228
+ try {
229
+ const apiKey = ctx.config.securitytrailsApiKey ?? process.env.SECURITYTRAILS_API_KEY;
230
+ if (apiKey) {
231
+ // ── SecurityTrails API path ──
232
+ await threatLimiter.acquire();
233
+ const res = await fetch(`https://api.securitytrails.com/v1/history/${encodeURIComponent(domain)}/dns/a`, {
234
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
235
+ headers: {
236
+ Accept: "application/json",
237
+ APIKEY: apiKey,
238
+ },
239
+ });
240
+ if (!res.ok) {
241
+ throw new Error(`SecurityTrails API error: ${res.status} ${res.statusText}`);
242
+ }
243
+ const data = (await res.json());
244
+ const records = (data.records ?? []).map((r) => ({
245
+ ips: r.values.map((v) => v.ip),
246
+ first_seen: r.first_seen,
247
+ last_seen: r.last_seen,
248
+ organizations: r.organizations ?? [],
249
+ type: r.type,
250
+ }));
251
+ // Collect all unique IPs with date ranges
252
+ const ipHistory = new Map();
253
+ for (const rec of records) {
254
+ for (const ip of rec.ips) {
255
+ const existing = ipHistory.get(ip);
256
+ if (!existing) {
257
+ ipHistory.set(ip, { first_seen: rec.first_seen, last_seen: rec.last_seen });
258
+ }
259
+ else {
260
+ if (rec.first_seen < existing.first_seen)
261
+ existing.first_seen = rec.first_seen;
262
+ if (rec.last_seen > existing.last_seen)
263
+ existing.last_seen = rec.last_seen;
264
+ }
265
+ }
266
+ }
267
+ const result = {
268
+ domain,
269
+ source: "securitytrails",
270
+ total_records: records.length,
271
+ unique_ips: ipHistory.size,
272
+ ip_history: Array.from(ipHistory.entries()).map(([ip, dates]) => ({
273
+ ip,
274
+ first_seen: dates.first_seen,
275
+ last_seen: dates.last_seen,
276
+ })),
277
+ records,
278
+ };
279
+ threatCache.set(cacheKey, result);
280
+ return json(result);
281
+ }
282
+ // ── Fallback: CT logs + multi-resolver comparison ──
283
+ const [ctEntries, currentRecords] = await Promise.all([
284
+ queryCrtSh(domain).catch(() => []),
285
+ resolveAll(domain, ["A", "AAAA"]),
286
+ ]);
287
+ // Extract unique IPs from current DNS
288
+ const currentIps = currentRecords.map((r) => r.data);
289
+ // Extract historical data from CT log timestamps
290
+ const certTimeline = [];
291
+ const seenSerials = new Set();
292
+ for (const entry of ctEntries.slice(0, 200)) {
293
+ if (seenSerials.has(entry.serial_number))
294
+ continue;
295
+ seenSerials.add(entry.serial_number);
296
+ certTimeline.push({
297
+ common_name: entry.common_name,
298
+ not_before: entry.not_before,
299
+ not_after: entry.not_after,
300
+ });
301
+ }
302
+ // Multi-resolver comparison for current state
303
+ const resolverIps = [];
304
+ const resolvers = ["8.8.8.8", "1.1.1.1", "9.9.9.9", "208.67.222.222"];
305
+ await Promise.all(resolvers.map(async (server) => {
306
+ try {
307
+ const resolver = createResolver(server);
308
+ const results = await resolver.resolve4(domain);
309
+ resolverIps.push({ resolver: server, ips: results });
310
+ }
311
+ catch {
312
+ resolverIps.push({ resolver: server, ips: [] });
313
+ }
314
+ }));
315
+ // Detect resolver inconsistencies
316
+ const allResolvedIps = new Set();
317
+ for (const r of resolverIps) {
318
+ for (const ip of r.ips)
319
+ allResolvedIps.add(ip);
320
+ }
321
+ const inconsistent = resolverIps.some((r) => r.ips.length > 0 && r.ips.sort().join(",") !== resolverIps[0].ips.sort().join(","));
322
+ const result = {
323
+ domain,
324
+ source: "ct_logs_and_resolvers",
325
+ note: "Set SECURITYTRAILS_API_KEY for richer passive DNS data",
326
+ current_ips: currentIps,
327
+ resolver_comparison: resolverIps,
328
+ resolver_inconsistency: inconsistent,
329
+ all_observed_ips: [...allResolvedIps],
330
+ ct_certificate_timeline: certTimeline.slice(0, 50),
331
+ total_ct_certs: certTimeline.length,
332
+ };
333
+ threatCache.set(cacheKey, result);
334
+ return json(result);
335
+ }
336
+ catch (err) {
337
+ return text(`Error querying passive DNS for ${domain}: ${err.message}`);
338
+ }
339
+ },
340
+ };
341
+ // ─── Tool 2: threat_cohosting ───
342
+ const threatCohosting = {
343
+ name: "threat_cohosting",
344
+ description: "Analyzes domain co-hosting by resolving the domain to its IP, performing reverse DNS (PTR) lookups, " +
345
+ "and searching CT logs for other domains on the same IP. Flags hosting with suspicious co-hosted domains.",
346
+ schema: {
347
+ domain: z.string().describe("The domain to analyze for co-hosting relationships (e.g. 'example.com')"),
348
+ },
349
+ async execute(args) {
350
+ const domain = args.domain;
351
+ const cacheKey = `threat_cohosting:${domain}`;
352
+ const cached = threatCache.get(cacheKey);
353
+ if (cached)
354
+ return json(cached);
355
+ try {
356
+ // Resolve domain to IPs
357
+ const resolver = createResolver();
358
+ let ips = [];
359
+ try {
360
+ ips = await resolver.resolve4(domain);
361
+ }
362
+ catch {
363
+ return text(`Error: Could not resolve ${domain} to any IP address.`);
364
+ }
365
+ if (ips.length === 0) {
366
+ return text(`Error: No A records found for ${domain}.`);
367
+ }
368
+ const cohostedResults = [];
369
+ for (const ip of ips.slice(0, 5)) {
370
+ // Reverse DNS
371
+ let ptrRecords = [];
372
+ try {
373
+ ptrRecords = await dns.reverse(ip);
374
+ }
375
+ catch {
376
+ // No PTR records
377
+ }
378
+ // CT log search for domains on this IP
379
+ let ctDomains = [];
380
+ try {
381
+ // crt.sh doesn't support IP search directly; we use PTR results
382
+ // Additionally, search for each PTR domain in CT
383
+ const ptDomainSet = new Set();
384
+ for (const ptr of ptrRecords) {
385
+ ptDomainSet.add(ptr.replace(/\.$/, ""));
386
+ }
387
+ ctDomains = [...ptDomainSet];
388
+ }
389
+ catch {
390
+ // CT lookup failed
391
+ }
392
+ cohostedResults.push({
393
+ ip,
394
+ ptr_records: ptrRecords,
395
+ ct_domains: ctDomains,
396
+ });
397
+ }
398
+ // Collect all unique co-hosted domains (excluding the queried domain)
399
+ const allCohosted = new Set();
400
+ for (const r of cohostedResults) {
401
+ for (const d of r.ptr_records) {
402
+ const cleaned = d.replace(/\.$/, "").toLowerCase();
403
+ if (cleaned !== domain.toLowerCase())
404
+ allCohosted.add(cleaned);
405
+ }
406
+ for (const d of r.ct_domains) {
407
+ const cleaned = d.replace(/\.$/, "").toLowerCase();
408
+ if (cleaned !== domain.toLowerCase())
409
+ allCohosted.add(cleaned);
410
+ }
411
+ }
412
+ // Flag suspicious patterns in co-hosted domains
413
+ const suspiciousPatterns = [
414
+ /phish/i, /malware/i, /spam/i, /hack/i, /exploit/i,
415
+ /bot/i, /trojan/i, /ransom/i, /scam/i, /fraud/i,
416
+ /fake/i, /steal/i, /crypt/i, /miner/i, /c2/i,
417
+ ];
418
+ const flags = [];
419
+ const suspiciousDomains = [];
420
+ for (const d of allCohosted) {
421
+ for (const pattern of suspiciousPatterns) {
422
+ if (pattern.test(d)) {
423
+ suspiciousDomains.push(d);
424
+ break;
425
+ }
426
+ }
427
+ }
428
+ if (suspiciousDomains.length > 0) {
429
+ flags.push(`${suspiciousDomains.length} co-hosted domain(s) match suspicious patterns`);
430
+ }
431
+ if (allCohosted.size > 50) {
432
+ flags.push(`High-density shared hosting: ${allCohosted.size} domains on same IP(s)`);
433
+ }
434
+ if (ips.length > 5) {
435
+ flags.push(`Domain resolves to multiple IPs (${ips.length}) — possible CDN/load balancer`);
436
+ }
437
+ const result = {
438
+ domain,
439
+ resolved_ips: ips,
440
+ cohosted_domain_count: allCohosted.size,
441
+ cohosted_domains: [...allCohosted].sort().slice(0, 100),
442
+ per_ip_details: cohostedResults,
443
+ suspicious_cohosted: suspiciousDomains,
444
+ flags,
445
+ risk_level: suspiciousDomains.length > 3
446
+ ? "high"
447
+ : suspiciousDomains.length > 0
448
+ ? "medium"
449
+ : "low",
450
+ };
451
+ threatCache.set(cacheKey, result);
452
+ return json(result);
453
+ }
454
+ catch (err) {
455
+ return text(`Error analyzing co-hosting for ${domain}: ${err.message}`);
456
+ }
457
+ },
458
+ };
459
+ // ─── Tool 3: threat_ip_to_domains ───
460
+ const threatIpToDomains = {
461
+ name: "threat_ip_to_domains",
462
+ description: "Resolves an IP address to all known domains and subdomains via reverse DNS (PTR records) " +
463
+ "and Certificate Transparency log searches. Returns all domains/subdomains hosted on the given IP.",
464
+ schema: {
465
+ ip: z.string().describe("The IP address to resolve to domains (e.g. '93.184.216.34')"),
466
+ },
467
+ async execute(args) {
468
+ const ip = args.ip;
469
+ const cacheKey = `threat_ip_to_domains:${ip}`;
470
+ const cached = threatCache.get(cacheKey);
471
+ if (cached)
472
+ return json(cached);
473
+ try {
474
+ // Step 1: Reverse DNS lookup
475
+ let ptrRecords = [];
476
+ try {
477
+ ptrRecords = await dns.reverse(ip);
478
+ }
479
+ catch {
480
+ // No PTR records — not unusual
481
+ }
482
+ // Step 2: Search CT logs using PTR results to discover related domains
483
+ const discoveredDomains = new Set();
484
+ for (const ptr of ptrRecords) {
485
+ const cleaned = ptr.replace(/\.$/, "");
486
+ discoveredDomains.add(cleaned);
487
+ // Extract base domain from PTR and search CT
488
+ const parts = cleaned.split(".");
489
+ if (parts.length >= 2) {
490
+ const baseDomain = parts.slice(-2).join(".");
491
+ try {
492
+ const ctEntries = await queryCrtSh(`%.${baseDomain}`);
493
+ for (const entry of ctEntries.slice(0, 200)) {
494
+ const names = entry.name_value.split("\n");
495
+ for (const name of names) {
496
+ const trimmed = name.trim().replace(/^\*\./, "");
497
+ if (trimmed)
498
+ discoveredDomains.add(trimmed);
499
+ }
500
+ }
501
+ }
502
+ catch {
503
+ // CT lookup failed for this base domain
504
+ }
505
+ }
506
+ }
507
+ // Step 3: Verify which discovered domains actually resolve to this IP
508
+ const verifiedDomains = [];
509
+ const unverifiedDomains = [];
510
+ const resolver = createResolver();
511
+ const domainsToCheck = [...discoveredDomains].slice(0, 100);
512
+ await Promise.all(domainsToCheck.map(async (d) => {
513
+ try {
514
+ const resolvedIps = await resolver.resolve4(d);
515
+ if (resolvedIps.includes(ip)) {
516
+ verifiedDomains.push(d);
517
+ }
518
+ else {
519
+ unverifiedDomains.push(d);
520
+ }
521
+ }
522
+ catch {
523
+ unverifiedDomains.push(d);
524
+ }
525
+ }));
526
+ const result = {
527
+ ip,
528
+ ptr_records: ptrRecords,
529
+ total_discovered: discoveredDomains.size,
530
+ verified_on_ip: verifiedDomains.sort(),
531
+ verified_count: verifiedDomains.length,
532
+ discovered_but_different_ip: unverifiedDomains.sort().slice(0, 50),
533
+ note: ptrRecords.length === 0
534
+ ? "No PTR records found — reverse DNS not configured for this IP"
535
+ : undefined,
536
+ };
537
+ threatCache.set(cacheKey, result);
538
+ return json(result);
539
+ }
540
+ catch (err) {
541
+ return text(`Error resolving IP ${ip} to domains: ${err.message}`);
542
+ }
543
+ },
544
+ };
545
+ // ─── Tool 4: threat_malicious_feed ───
546
+ const threatMaliciousFeed = {
547
+ name: "threat_malicious_feed",
548
+ description: "Checks a domain against free threat intelligence feeds: DNS-based blocklists (Spamhaus DBL, SURBL) " +
549
+ "via DNS lookups, and HTTP-based feeds (URLhaus abuse.ch). Returns feed hits and threat categories.",
550
+ schema: {
551
+ domain: z.string().describe("The domain to check against threat intelligence feeds (e.g. 'suspicious-domain.com')"),
552
+ },
553
+ async execute(args) {
554
+ const domain = args.domain;
555
+ const cacheKey = `threat_malicious_feed:${domain}`;
556
+ const cached = threatCache.get(cacheKey);
557
+ if (cached)
558
+ return json(cached);
559
+ try {
560
+ const feedResults = [];
561
+ // ── DNS-based blocklists ──
562
+ const dnsFeeds = [
563
+ { name: "Spamhaus DBL", domain: "dbl.spamhaus.org", category: "spam/malware" },
564
+ { name: "SURBL Multi", domain: "multi.surbl.org", category: "spam/phishing" },
565
+ { name: "Spamhaus ZRD", domain: "zrd.spamhaus.org", category: "newly-registered" },
566
+ { name: "SURBL Phishing", domain: "phishing.surbl.org", category: "phishing" },
567
+ { name: "SURBL Malware", domain: "malware.surbl.org", category: "malware" },
568
+ { name: "URIBL", domain: "multi.uribl.com", category: "spam" },
569
+ ];
570
+ await Promise.all(dnsFeeds.map(async (feed) => {
571
+ const listed = await lookupDnsbl(domain, feed.domain);
572
+ feedResults.push({
573
+ feed: feed.name,
574
+ type: "dns",
575
+ listed,
576
+ category: feed.category,
577
+ });
578
+ }));
579
+ // ── HTTP-based: URLhaus ──
580
+ try {
581
+ await threatLimiter.acquire();
582
+ const formBody = new URLSearchParams();
583
+ formBody.set("host", domain);
584
+ const urlhausRes = await fetch("https://urlhaus-api.abuse.ch/v1/host/", {
585
+ method: "POST",
586
+ body: formBody,
587
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
588
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
589
+ });
590
+ if (urlhausRes.ok) {
591
+ const data = (await urlhausRes.json());
592
+ const isListed = data.query_status === "ok" && (data.urls_online ?? 0) > 0;
593
+ const threats = (data.urls ?? [])
594
+ .map((u) => u.threat)
595
+ .filter((t) => !!t);
596
+ const uniqueThreats = [...new Set(threats)];
597
+ feedResults.push({
598
+ feed: "URLhaus (abuse.ch)",
599
+ type: "http",
600
+ listed: isListed,
601
+ category: uniqueThreats.length > 0 ? uniqueThreats.join(", ") : "malware",
602
+ detail: isListed
603
+ ? `${data.urls_online ?? 0} active malicious URL(s) found. Reference: ${data.urlhaus_reference ?? "N/A"}`
604
+ : undefined,
605
+ });
606
+ }
607
+ }
608
+ catch {
609
+ feedResults.push({
610
+ feed: "URLhaus (abuse.ch)",
611
+ type: "http",
612
+ listed: false,
613
+ category: "malware",
614
+ detail: "URLhaus lookup failed (timeout or error)",
615
+ });
616
+ }
617
+ const listedFeeds = feedResults.filter((f) => f.listed);
618
+ const categories = [...new Set(listedFeeds.map((f) => f.category))];
619
+ const result = {
620
+ domain,
621
+ feeds_checked: feedResults.length,
622
+ feeds_listed: listedFeeds.length,
623
+ threat_categories: categories,
624
+ risk_level: listedFeeds.length >= 3
625
+ ? "critical"
626
+ : listedFeeds.length >= 2
627
+ ? "high"
628
+ : listedFeeds.length === 1
629
+ ? "medium"
630
+ : "clean",
631
+ feed_results: feedResults,
632
+ };
633
+ threatCache.set(cacheKey, result);
634
+ return json(result);
635
+ }
636
+ catch (err) {
637
+ return text(`Error checking threat feeds for ${domain}: ${err.message}`);
638
+ }
639
+ },
640
+ };
641
+ // ─── Tool 5: threat_c2_detect ───
642
+ const threatC2Detect = {
643
+ name: "threat_c2_detect",
644
+ description: "Analyzes a batch of domains for Command & Control (C2) indicators: DGA score (entropy, consonant ratio), " +
645
+ "fast-flux detection (multiple resolves), very low TTL, and DNSBL listings. Returns per-domain C2 probability score (0-100).",
646
+ schema: {
647
+ domains: z.array(z.string()).describe("Array of domains to analyze for C2/DGA indicators (e.g. ['abc123xyz.com', 'normal-site.com'])"),
648
+ },
649
+ async execute(args) {
650
+ const domains = args.domains;
651
+ if (domains.length === 0) {
652
+ return text("No domains provided for C2 detection.");
653
+ }
654
+ try {
655
+ const results = await Promise.all(domains.slice(0, 100).map(async (domain) => {
656
+ let score = 0;
657
+ const indicators = [];
658
+ // ── DGA Analysis (entropy + consonant ratio) ──
659
+ const domainBase = domain.split(".").slice(0, -1).join(".");
660
+ const entropy = shannonEntropy(domainBase);
661
+ const consRatio = consonantRatio(domainBase);
662
+ // High entropy → likely DGA
663
+ if (entropy >= 4.0) {
664
+ score += 30;
665
+ indicators.push(`Very high entropy (${entropy.toFixed(2)}) — strong DGA indicator`);
666
+ }
667
+ else if (entropy >= 3.5) {
668
+ score += 15;
669
+ indicators.push(`Elevated entropy (${entropy.toFixed(2)}) — possible DGA`);
670
+ }
671
+ // High consonant ratio → likely DGA
672
+ if (consRatio > 0.75) {
673
+ score += 20;
674
+ indicators.push(`High consonant ratio (${(consRatio * 100).toFixed(1)}%) — DGA-like character distribution`);
675
+ }
676
+ else if (consRatio > 0.65) {
677
+ score += 10;
678
+ indicators.push(`Elevated consonant ratio (${(consRatio * 100).toFixed(1)}%)`);
679
+ }
680
+ // Short numerical or hex-like patterns
681
+ if (/^[a-f0-9]{8,}$/.test(domainBase)) {
682
+ score += 15;
683
+ indicators.push("Domain base is entirely hex characters — possible DGA or C2");
684
+ }
685
+ // ── Fast-Flux Detection (multiple resolves with different IPs) ──
686
+ const resolverList = ["8.8.8.8", "1.1.1.1", "9.9.9.9"];
687
+ const allIps = new Set();
688
+ let minTtl = Infinity;
689
+ await Promise.all(resolverList.map(async (server) => {
690
+ try {
691
+ const resolver = createResolver(server);
692
+ const results = await resolver.resolve4(domain, { ttl: true });
693
+ for (const r of results) {
694
+ allIps.add(r.address);
695
+ if (r.ttl < minTtl)
696
+ minTtl = r.ttl;
697
+ }
698
+ }
699
+ catch {
700
+ // Resolution failed
701
+ }
702
+ }));
703
+ // Fast-flux: many unique IPs
704
+ if (allIps.size >= 5) {
705
+ score += 20;
706
+ indicators.push(`Fast-flux detected: ${allIps.size} unique IPs across resolvers`);
707
+ }
708
+ else if (allIps.size >= 3) {
709
+ score += 10;
710
+ indicators.push(`Multiple IPs (${allIps.size}) across resolvers — possible fast-flux`);
711
+ }
712
+ // Very low TTL
713
+ if (minTtl !== Infinity && minTtl <= 60) {
714
+ score += 15;
715
+ indicators.push(`Very low TTL (${minTtl}s) — fast-flux/C2 indicator`);
716
+ }
717
+ else if (minTtl !== Infinity && minTtl <= 300) {
718
+ score += 5;
719
+ indicators.push(`Low TTL (${minTtl}s)`);
720
+ }
721
+ // ── DNSBL check (Spamhaus DBL) ──
722
+ const isBlocklisted = await lookupDnsbl(domain, "dbl.spamhaus.org");
723
+ if (isBlocklisted) {
724
+ score += 15;
725
+ indicators.push("Listed on Spamhaus DBL — known malicious domain");
726
+ }
727
+ // Clamp score
728
+ score = Math.max(0, Math.min(100, score));
729
+ return {
730
+ domain,
731
+ c2_probability: score,
732
+ risk_level: score >= 70
733
+ ? "critical"
734
+ : score >= 50
735
+ ? "high"
736
+ : score >= 30
737
+ ? "medium"
738
+ : score >= 15
739
+ ? "low"
740
+ : "clean",
741
+ entropy: Math.round(entropy * 1000) / 1000,
742
+ consonant_ratio: Math.round(consRatio * 1000) / 1000,
743
+ resolved_ips: [...allIps],
744
+ min_ttl: minTtl === Infinity ? null : minTtl,
745
+ dnsbl_listed: isBlocklisted,
746
+ indicators,
747
+ };
748
+ }));
749
+ const highRisk = results.filter((r) => r.c2_probability >= 50);
750
+ return json({
751
+ total_analyzed: results.length,
752
+ high_risk_count: highRisk.length,
753
+ results,
754
+ high_risk_domains: highRisk,
755
+ });
756
+ }
757
+ catch (err) {
758
+ return text(`Error running C2 detection: ${err.message}`);
759
+ }
760
+ },
761
+ };
762
+ // ─── Tool 6: threat_actor_infra ───
763
+ const threatActorInfra = {
764
+ name: "threat_actor_infra",
765
+ description: "Maps domain infrastructure fingerprint: NS, MX, IP, ASN (via Team Cymru), registrar (RDAP). " +
766
+ "Cross-references shared infrastructure to discover related domains via shared nameservers, mail servers, and IPs.",
767
+ schema: {
768
+ domain: z.string().describe("The domain to map infrastructure for (e.g. 'example.com')"),
769
+ },
770
+ async execute(args) {
771
+ const domain = args.domain;
772
+ const cacheKey = `threat_actor_infra:${domain}`;
773
+ const cached = threatCache.get(cacheKey);
774
+ if (cached)
775
+ return json(cached);
776
+ try {
777
+ // ── Resolve all records ──
778
+ const records = await resolveAll(domain, ["A", "AAAA", "NS", "MX", "SOA"]);
779
+ const ips = records.filter((r) => r.type === "A" || r.type === "AAAA").map((r) => r.data);
780
+ const nsRecords = records.filter((r) => r.type === "NS").map((r) => r.data);
781
+ const mxRecords = records.filter((r) => r.type === "MX").map((r) => {
782
+ const parts = r.data.split(" ");
783
+ return { priority: parseInt(parts[0], 10) || 0, exchange: parts.slice(1).join(" ") };
784
+ });
785
+ const soaRecord = records.find((r) => r.type === "SOA");
786
+ // ── ASN lookup for each IP ──
787
+ const asnResults = [];
788
+ await Promise.all(ips.slice(0, 10).map(async (ip) => {
789
+ const asnInfo = await cymruAsnLookup(ip);
790
+ if (asnInfo) {
791
+ asnResults.push({ ip, ...asnInfo });
792
+ }
793
+ }));
794
+ // ── RDAP lookup for registrar ──
795
+ const rdapInfo = await rdapLookup(domain);
796
+ // ── Resolve NS IPs to find shared infrastructure ──
797
+ const nsIps = new Map();
798
+ const resolver = createResolver();
799
+ await Promise.all(nsRecords.slice(0, 10).map(async (ns) => {
800
+ try {
801
+ const resolved = await resolver.resolve4(ns);
802
+ nsIps.set(ns, resolved);
803
+ }
804
+ catch {
805
+ nsIps.set(ns, []);
806
+ }
807
+ }));
808
+ // ── Check NS diversity ──
809
+ const nsSubnets = new Set();
810
+ for (const [, nsIpList] of nsIps) {
811
+ for (const nsIp of nsIpList) {
812
+ const subnet = nsIp.split(".").slice(0, 3).join(".");
813
+ nsSubnets.add(subnet);
814
+ }
815
+ }
816
+ // ── Infrastructure fingerprint ──
817
+ const fingerprint = {
818
+ nameservers: nsRecords.sort(),
819
+ nameserver_ips: Object.fromEntries(nsIps),
820
+ ns_subnets: [...nsSubnets].sort(),
821
+ ns_diversity: nsSubnets.size,
822
+ mail_servers: mxRecords.sort((a, b) => a.priority - b.priority),
823
+ ips,
824
+ asn_info: asnResults,
825
+ registrar: rdapInfo?.registrar ?? "unknown",
826
+ registration_dates: {
827
+ created: rdapInfo?.created ?? "unknown",
828
+ updated: rdapInfo?.updated ?? "unknown",
829
+ expires: rdapInfo?.expires ?? "unknown",
830
+ },
831
+ soa: soaRecord?.data ?? null,
832
+ };
833
+ // ── Cross-reference: find domains sharing same NS ──
834
+ const sharedInfraHints = [];
835
+ if (nsRecords.length > 0) {
836
+ sharedInfraHints.push(`Nameservers: ${nsRecords.join(", ")} — other domains using these NS may be related`);
837
+ }
838
+ if (mxRecords.length > 0) {
839
+ const mxHosts = mxRecords.map((m) => m.exchange);
840
+ sharedInfraHints.push(`Mail servers: ${mxHosts.join(", ")} — shared MX infrastructure`);
841
+ }
842
+ const uniqueAsns = [...new Set(asnResults.map((a) => `AS${a.asn} (${a.asName})`))];
843
+ if (uniqueAsns.length > 0) {
844
+ sharedInfraHints.push(`ASN(s): ${uniqueAsns.join(", ")}`);
845
+ }
846
+ const result = {
847
+ domain,
848
+ infrastructure_fingerprint: fingerprint,
849
+ shared_infrastructure_hints: sharedInfraHints,
850
+ summary: {
851
+ ip_count: ips.length,
852
+ ns_count: nsRecords.length,
853
+ mx_count: mxRecords.length,
854
+ unique_asns: uniqueAsns.length,
855
+ ns_diversity_score: nsSubnets.size,
856
+ registrar: rdapInfo?.registrar ?? "unknown",
857
+ },
858
+ };
859
+ threatCache.set(cacheKey, result);
860
+ return json(result);
861
+ }
862
+ catch (err) {
863
+ return text(`Error mapping infrastructure for ${domain}: ${err.message}`);
864
+ }
865
+ },
866
+ };
867
+ // ─── Tool 7: threat_sinkhole_check ───
868
+ const threatSinkholeCheck = {
869
+ name: "threat_sinkhole_check",
870
+ description: "Resolves a domain and checks if its IP belongs to known sinkhole operators (Microsoft, Shadowserver, " +
871
+ "abuse.ch, FBI, Palo Alto, Kaspersky, CrowdStrike, etc.). Returns whether the domain is sinkholed and by which operator.",
872
+ schema: {
873
+ domain: z.string().describe("The domain to check for sinkhole status (e.g. 'known-malware-domain.com')"),
874
+ },
875
+ async execute(args) {
876
+ const domain = args.domain;
877
+ const cacheKey = `threat_sinkhole_check:${domain}`;
878
+ const cached = threatCache.get(cacheKey);
879
+ if (cached)
880
+ return json(cached);
881
+ try {
882
+ // Resolve domain
883
+ const resolver = createResolver();
884
+ let ips = [];
885
+ try {
886
+ ips = await resolver.resolve4(domain);
887
+ }
888
+ catch {
889
+ // NXDOMAIN could also indicate sinkhole or takedown
890
+ }
891
+ let ipv6s = [];
892
+ try {
893
+ ipv6s = await resolver.resolve6(domain);
894
+ }
895
+ catch {
896
+ // No AAAA records
897
+ }
898
+ // Check CNAME chain for sinkhole patterns
899
+ let cnameChain = [];
900
+ try {
901
+ const cnames = await resolver.resolveCname(domain);
902
+ cnameChain = cnames;
903
+ }
904
+ catch {
905
+ // No CNAME
906
+ }
907
+ // Check resolved IPs against known sinkhole ranges
908
+ const sinkholeMatches = [];
909
+ for (const ip of ips) {
910
+ for (const sinkhole of SINKHOLE_OPERATORS) {
911
+ // Check IP ranges
912
+ for (const range of sinkhole.ranges) {
913
+ if (ipMatchesPrefix(ip, range.prefix)) {
914
+ sinkholeMatches.push({
915
+ ip,
916
+ operator: sinkhole.operator,
917
+ matchType: `IP range match: ${range.prefix}0/${range.cidr ?? 24}`,
918
+ });
919
+ }
920
+ }
921
+ }
922
+ }
923
+ // Check CNAME chain for sinkhole patterns
924
+ for (const cname of cnameChain) {
925
+ for (const sinkhole of SINKHOLE_OPERATORS) {
926
+ for (const pattern of sinkhole.patterns) {
927
+ if (cname.toLowerCase().includes(pattern.toLowerCase())) {
928
+ sinkholeMatches.push({
929
+ ip: cname,
930
+ operator: sinkhole.operator,
931
+ matchType: `CNAME pattern match: ${pattern}`,
932
+ });
933
+ }
934
+ }
935
+ }
936
+ }
937
+ // Check PTR records for sinkhole patterns
938
+ const ptrSinkholeMatches = [];
939
+ for (const ip of ips) {
940
+ try {
941
+ const ptrs = await dns.reverse(ip);
942
+ for (const ptr of ptrs) {
943
+ for (const sinkhole of SINKHOLE_OPERATORS) {
944
+ for (const pattern of sinkhole.patterns) {
945
+ if (ptr.toLowerCase().includes(pattern.toLowerCase())) {
946
+ ptrSinkholeMatches.push({ ip, ptr, operator: sinkhole.operator });
947
+ }
948
+ }
949
+ }
950
+ }
951
+ }
952
+ catch {
953
+ // No PTR
954
+ }
955
+ }
956
+ const isSinkholed = sinkholeMatches.length > 0 || ptrSinkholeMatches.length > 0;
957
+ const operators = [...new Set([
958
+ ...sinkholeMatches.map((m) => m.operator),
959
+ ...ptrSinkholeMatches.map((m) => m.operator),
960
+ ])];
961
+ // Check if domain resolves to NXDOMAIN (possible takedown without sinkhole)
962
+ const isNxdomain = ips.length === 0 && ipv6s.length === 0;
963
+ const result = {
964
+ domain,
965
+ resolved_ips: ips,
966
+ resolved_ipv6s: ipv6s,
967
+ cname_chain: cnameChain,
968
+ is_sinkholed: isSinkholed,
969
+ is_nxdomain: isNxdomain,
970
+ sinkhole_operators: operators,
971
+ sinkhole_matches: sinkholeMatches,
972
+ ptr_sinkhole_matches: ptrSinkholeMatches,
973
+ status: isSinkholed
974
+ ? `SINKHOLED by ${operators.join(", ")}`
975
+ : isNxdomain
976
+ ? "NXDOMAIN — domain does not resolve (possibly taken down)"
977
+ : "ACTIVE — not detected in known sinkhole ranges",
978
+ note: "This check uses a known-sinkhole IP/pattern database. " +
979
+ "New or private sinkholes may not be detected.",
980
+ };
981
+ threatCache.set(cacheKey, result);
982
+ return json(result);
983
+ }
984
+ catch (err) {
985
+ return text(`Error checking sinkhole status for ${domain}: ${err.message}`);
986
+ }
987
+ },
988
+ };
989
+ // ─── Export All Threat Tools ───
990
+ export const threatTools = [
991
+ threatPassiveDns,
992
+ threatCohosting,
993
+ threatIpToDomains,
994
+ threatMaliciousFeed,
995
+ threatC2Detect,
996
+ threatActorInfra,
997
+ threatSinkholeCheck,
998
+ ];
999
+ //# sourceMappingURL=index.js.map